# KDB (KARELA DataBase): full documentation
kdb 0.2.0-dev, built 2026-10-22. Source of each part: https://raw.githubusercontent.com/Amritshr/kdb-knowledge/main/
# About KDB
> What KDB is, who made it, what you can use it for, how it keeps your data safe, how fast it is and how it is tested.
- Category: Getting started
- Keywords: about, KDB, KARELA, KARELA DataBase, what is KDB, creator, author, Amrit Shrestha, created by, use cases, what can I use KDB for, time-series database, relational database, IoT, sensors, logs, metrics, accounting, ledger, app backend, full-text search, PostgreSQL compatible, data safety, comments, suggestions, feedback
- See also: [Quick start](quick-start.md), [What's new](whats-new.md), [Install packages and the Claude skill](install-packages.md), [Database models: relational, hierarchical, network, NoSQL, object](data-models.md), [Test report](test-report.md), [Benchmark report](benchmark-report.md), [Limits](limits.md), [Backup, check, repair and restore](backup-and-safety.md)
KDB (KARELA DataBase) is a small, general-purpose SQL database written in C++17. It runs as **one program** with no other software needed, and it keeps your data safe from crashes, power cuts and disk damage.
| | |
|---|---|
| Name | KARELA DataBase |
| Created by | **Amrit Shrestha** |
| Version | 0.2.0-dev |
| Written in | C++17, with no third-party libraries in the core |
| Tested on | Windows 11, and Linux (Debian 12 and Alpine) in Docker |
| What changed lately | [What's new](whats-new.md) |
Send comments and suggestions from the web console: Help menu > Comments and suggestions.
Source: README.md, CMakeLists.txt, reports/linux-build.md, reports/docker-image.md.
## One program, three doors
`kdb serve` starts one server. You reach the same databases three ways:
| Door | Port | For |
|---|---|---|
| [Web console](web-console.md) | 9900 | a browser: run SQL, see tables and charts, dashboards, settings |
| [HTTP API](http-overview.md) | 9900 | any language or script: send SQL, get JSON back |
| [PostgreSQL protocol](postgres-protocol.md) | 8832 | `psql`, PostgreSQL drivers and apps written for PostgreSQL |
> **Diagram:** Three kinds of client, a web browser, apps and scripts, and PostgreSQL tools, reach the one kdb program on ports 9900, 9900 and 8832. Inside the program the SQL engine sits on top of the storage engine.
>
> Every door reaches the same SQL engine and the same data. The web console, the help and the Claude skill are plain folders next to the program.
- A database is a folder. One server can hold many databases.
- KDB is one server on one machine. It is not a cluster that spreads data over many machines.
- Nothing else is built into the program. The web console, the help and the Claude skill are the folders `web/`, `help/` and `skill/` next to `kdb`, so a new console or help needs no new program. See [where kdb finds them](install-packages.md#folders).
- You can also run it in Docker: the image (about 44 MB) holds the program and those three folders. See [install-packages](install-packages.md#docker).
Ask the running server which version it is:
**Example: Which KDB is this?** (sample data: sample)
```sql
SHOW VERSION;
```
Result:
| item (STRING) | value (STRING) |
|---|---|
| kdb version | 0.2.0-dev |
| database | demo |
| features used | batched_log_sync, block_stats, compressed_log, partition_format_2 |
| feature list | saved: batched_log_sync, block_stats, compressed_log, partition_format_2 |
| features this KDB reads | 51: views, partition_format_2, block_stats, runs, auto_meta, json, bytes, check, indexes, routines, users, audit, table_grants, totp, trash, database_settings, dialects, pg_catalog_parts, sequences... |
(5 rows)
Source: README.md, reports/docker-image.md, src/server/support_files.h, docker/Dockerfile.
## What you can use KDB for
KDB is not tied to one market or one kind of data. It holds **time-series tables** (rows that arrive in time order) and ordinary **relational tables** (rows linked by keys), and one query may use both.
| Use | What KDB gives you | Help |
|---|---|---|
| Market and price data | OHLC candles from raw rows, the latest row per symbol, technical indicators, trade lists loaded faster than QuestDB | [sample-by](sample-by.md), [latest-on](latest-on.md), [functions-indicators](functions-indicators.md), [candles-cookbook](candles-cookbook.md) |
| IoT and sensor readings | many small rows per second over the line protocol, grouped by device and time, duplicates dropped | [line-protocol](line-protocol.md), [partitions-and-dedup](partitions-and-dedup.md), [sample-by](sample-by.md) |
| Logs, metrics and monitoring | append-only events kept in time partitions, filtered and summed by time, old days dropped on a schedule | [create-table](create-table.md), [time-and-time-zones](time-and-time-zones.md), [group-by](group-by.md), [console-keep-data](console-keep-data.md) |
| Accounting and ledgers | exact `DECIMAL` money, `CHECK` rules, `BEGIN` / `COMMIT` / `ROLLBACK`, writers of different tables at the same time | [column-types](column-types.md), [constraints](constraints.md), [transactions](transactions.md) |
| Users, logins and app backends | password hashes, random tokens, UUIDs, unique keys, indexes, database users and roles | [functions-security](functions-security.md), [indexes](indexes.md), [users-and-roles](users-and-roles.md) |
| News, documents and forums | full-text search with ranking, phrases and prefixes answered from the index, `JSON` columns for loose data | [full-text-search](full-text-search.md), [functions-json](functions-json.md) |
| Apps already written for PostgreSQL | connect normal PostgreSQL tools and drivers to port 8832 | [postgres-protocol](postgres-protocol.md), [functions-postgres](functions-postgres.md) |
| Trees, networks and documents | parent-child tables, links between items, `WITH RECURSIVE` | [data-models](data-models.md) |
| Charts, dashboards and scheduled jobs | 22 chart types from any query, dashboards saved in the database, timers that run a procedure every minute, day or month | [web-console](web-console.md), [timers](timers.md) |
What does not work yet is listed on [limits](limits.md).
Source: README.md "What you can use KDB for", docs/help/topics/data-models.md.
## How your data is kept safe
| Protection | What it does for you |
|---|---|
| Checksum on every block | Every file is split into blocks, and each block has a CRC32C checksum. If one bit changes on the disk, KDB finds it and knows which block is bad. |
| Write-ahead log | Every change is written to the log and saved to disk before KDB says "committed". After a crash the log is played again, so no committed change is lost. |
| Copy-on-write files | A data file is never changed after it is written. KDB writes a new version and then switches to it, so a crash never leaves a half-changed file. |
| Two manifest copies | The list of current files is kept twice. KDB always writes over the older copy, so one good copy is always left. |
| Repair ladder | A damaged file is repaired from an older version first, then from a backup. If both fail, only that file is marked damaged and the rest still works. |
| Verified backups | After a backup, KDB reads every file of it again. A backup that is not finished or not correct is never used. |
| Point-in-time restore | Restore a backup and replay the log up to an exact time, for example just before a bad import. |
With `--sync-commits batch` (a disk flush every 10 ms, the faster setting) a power cut can lose writes from the last moments that were already confirmed. `--sync-commits always` flushes on every commit and loses nothing. See [backup-and-safety](backup-and-safety.md) and [protection](protection.md).
Source: README.md "What protects your data", reports/qa-tests.md.
## How fast it is
KDB is measured against QuestDB (time-series) and PostgreSQL (relational and search) on the same PC, in the same minutes. A few lines from the reports:
| Work | KDB | The other database | Report |
|---|---|---|---|
| Bulk load of candles, 20,000 rows a request | 1.04M to 1.36M rows/s | QuestDB 865k rows/s | vs QuestDB |
| Load of a trade list (814,535 real trades, no DEDUP KEY) | 918k rows/s | QuestDB 678k rows/s | keyless runs |
| SQL `INSERT`, 100 rows a statement | 144k to 166k rows/s | QuestDB 6.8k to 8.9k rows/s | vs QuestDB |
| Search 200,000 news articles: count of a common word | 3.6 ms | PostgreSQL 18: 250 ms | news search |
| Search: a two-word phrase | 9.7 ms | PostgreSQL 18: 295 ms | news search |
| 8 clients doing bank transfers | 1,981 a second | PostgreSQL 18: 890 a second | table locks |
Where KDB is still slower:
| Work | KDB | The other database |
|---|---|---|
| A report joining three tables with `GROUP BY` and `LEFT JOIN` | 19 a second | PostgreSQL 68 a second |
| `ORDER BY` over many groups, and big JSON answers | slower | QuestDB |
The full tables, how each number was measured and every place KDB still loses are on [benchmark-report](benchmark-report.md). New speed-ups and their before and after numbers are on [what's new](whats-new.md).
Source: reports/benchmark-vs-questdb.md, reports/keyless-runs.md, reports/news-search.md, reports/table-locks-merge.md, reports/benchmark-postgres.md, reports/floorsheet-filter-speed.md.
## How it is tested
| Test | What it does | Result |
|---|---|---|
| Crash test | kills the program at a random moment while it writes, 1,000 times | every confirmed change there, no money lost |
| Power-cut test | throws away every write the disk had not flushed, 1,000 times, also during recovery | pass, 0 repairs needed |
| Damage test | flips bytes, cuts and deletes files, 1,000 rounds | damage always found, 0 wrong answers |
| Full disk | a full disk at 24 moments in 3 log modes | committed rows intact, HTTP 507, writes go on when space returns |
| Junk text | 12,132 statements of broken UTF-8, NUL bytes, huge texts | no crash, no hang |
| Soak | 30 minutes of mixed load: 19.9M line-protocol rows, 64,637 queries | 0 errors, every row accounted for |
| C++ test programs | 52 programs, also run with ASan and UBSan (tools that find memory mistakes) | pass |
| Console and HTTP API | Node tests that drive the console in Chrome and call every route | pass |
| Help examples | every example on these help pages is run on each help build | about 1,900, 0 failed |
Details: [test-report](test-report.md). Tests that are not done yet (a 48-hour soak, a real full disk) are listed there too.
Source: reports/qa-tests.md, reports/disk-full.md, reports/text-fuzz.md, reports/soak.md, reports/help-coverage.md.
---
# What's new
> Everything added to KDB since 2026-09-12, grouped by what it does for you, with the measured number and the topic that explains it.
- Category: Getting started
- Keywords: whats new, whats new, new features, changes, changelog, release notes, recent changes, since 2026-09-12, zone maps, saved stats, warm at open, table locks, group commit, keyless runs, phrase search, word positions, disk full, format
- See also: [About KDB](about-kdb.md), [Benchmark report](benchmark-report.md), [Test report](test-report.md), [Performance tips](performance-tips.md), [Limits](limits.md)
This page lists what was added to KDB from 2026-09-12 to 2026-09-24. Each line says what changed, the number measured before and after (where there is one), and where to read more.
- Numbers are medians on one PC (Intel i9-14900HX, 32 GB). The reports say how each was measured.
- "A -> B" means before -> after, on the same machine in the same minutes.
- A switch like `KDB_NO_ZONE_MAPS=1` turns a new speed-up off. It is there so an answer can be checked against the old path, not for normal use.
## 2026-10-18: streamed reads over the Postgres port run three to four times faster; a view's one-day read in half the time
| What changed | Measured (10M trades in 145 day partitions, the server on 2 CPUs, Windows, a Go pgx client reading every row, 006's way) | Read more |
|---|---|---|
| A driver that describes a query before it runs it (pgx, psycopg, JDBC, npgsql all send `Describe`, `Execute` and `Sync` at once) no longer has every row written to a temporary file and read back: a plain `SELECT` of a table's columns is described from the table, and any other query runs as its `Execute` would, the rows streamed. | `SELECT * FROM fs` (10M rows, 8 columns): 6.6-7.4 s -> 1.5-1.8 s (1.4M -> 5.7-6.6M rows/s, the two builds side by side); the 34M-row floor sheet in Docker took 46 s | [big answers stream](postgres-protocol.md#streaming) |
| The rows of a streamed answer are written straight from the stored columns, no value object per cell, and sent from a thread of their own while the next run is written. | server CPU per 10M rows 1.9 s -> 1.3 s | [big answers stream](postgres-protocol.md#streaming) |
| A view that is rewritten before it runs (a day's conditions pushed in, aggregates pushed into `UNION ALL` parts) is copied as a tree instead of printed and parsed again on every run. | one day of a view over two materialized views (91 rows): 0.33-0.35 ms -> 0.17 ms per request over the Postgres port, the two builds side by side; the server's own time (`pg_stat_statements`) 0.21 -> 0.13 ms | [views](views.md#where-pushdown) |
## 2026-10-18: a join to a small table runs as a key lookup; a day's grouping in fewer steps
| What changed | Measured (10M floorsheet trades, 2 CPUs, 1.5 GB memory budget, WSL with an emulated 2-CPU quota) | Read more |
|---|---|---|
| A grouped view joined to a total of itself over some of its keys (`df d JOIN (SELECT time, symbol, sum(buy_qty) AS tot FROM df GROUP BY time, symbol) t ON d.time = t.time AND d.symbol = t.symbol`, each broker beside the symbol-day's total) makes the totals inside the view's own grouping: no row of the view is joined. An `INNER JOIN` on all of t's keys, t's aggregates `sum` / `min` / `max` of the view's `sum` / `count` / `min` / `max` outputs, a `WHERE` that narrows by those keys and by aggregates only. `EXPLAIN` says "to its own totals"; `KDB_NO_SELF_JOIN_TOTALS=1` turns it off. | OTM filter, one day: 7.6 -> 6.3 ms (side by side on a busy host) | [views](views.md#where-pushdown) |
| A `HAVING` that reads only aggregates (a `CASE` flag, comparisons, `AND` / `OR` / `NOT`, `IN`, `BETWEEN`) is worked out column-wise over all the groups at once, with the same `NULL` rules, instead of one group at a time. An average, a first/last, a DECIMAL sum or whole-number arithmetic in the test still goes group by group; `KDB_NO_VECTOR_HAVING=1` turns it off. | OTM flag test over a day's 10,000 groups: 3.9 -> 1.4 ms (the step alone) | [HAVING](group-by.md#having) |
| A `GROUP BY` over a view joined to a small table whose columns are only group keys (`df d JOIN sector s ON d.symbol = s.symbol GROUP BY d.time, d.broker, s.sector`) runs the join as a lookup of the key inside the view's own grouping: no row of the view is made. Only an `INNER JOIN` on one column, a table of up to 65,536 rows that holds each key once, and columns of texts or whole numbers without NULLs. `EXPLAIN` says "run as a lookup of". | broker-sector, one day: 4.9 -> 1.1 ms; over a year: 31 -> 16 ms | [views](views.md#where-pushdown) |
| A `GROUP BY` of two to four keys (`time, symbol, broker`) keeps them in one 64-bit number, each key in the bits its saved stats call for, so the merge compares numbers and not 48-byte keys. | daily filter, one day: 3.6 -> 2.6 ms; OTM filter, one day: 6.0 -> 5.4 ms | [GROUP BY](group-by.md) |
| The small steps around a day's grouping cost less: the zero and one columns of a `UNION ALL` part are kept across queries instead of being made as long as the partition each time, the merge tables are sized once from the pieces, and with few groups the steps after the scan run on one thread instead of a pass over the pool each. `KDB_FLAT_TRACE=1` prints where a grouping's time goes. | (in the numbers above) | [GROUP BY](group-by.md) |
## 2026-10-17: sums over a year of windows and of buy/sell views on the column engine
| What changed | Measured (36M floorsheet trades over 2 years, 2 CPUs, 1.5 GB memory budget, WSL) | Read more |
|---|---|---|
| A `GROUP BY` over a subquery of `row_number()`, `rank()`, `dense_rank()`, `lead()` or `lag()` over one table runs on the column engine: the window values are worked out partition by partition, then grouped. The `PARTITION BY` must be the first columns the table is sorted by, with its time column among them. | pump-and-dump summed over 1 / 2 years: 4.7 / 9.0 s -> 0.61 / 1.2 s | [window functions](functions-window.md) |
| A sum over a view that joins the buy side and the sell side of one table with `UNION ALL` reads only the columns it uses, and each side finds its groups as runs of the symbol the table is sorted by. | daily filter summed over 1 year: 0.88 -> 0.35 s; sector turnover over 2 years: 1.47 -> 0.46 s; broker invest over 2 years: 1.71 -> 0.54 s | [UNION ALL](union-subqueries.md) |
| A `GROUP BY` of a SYMBOL and a LONG column keeps both keys in one 64-bit number when the saved stats of every partition read keep the LONG in 32 bits; over the parts of a `UNION ALL` too. | broker holding (symbol x broker) over 2 years: 3.77 -> 1.75 s | [GROUP BY](group-by.md) |
| `CAST(0 AS LONG)` and other casts of a number that cannot change it count as that number in a sum of a `CASE` on the column engine. | (the pump-and-dump view writes its zeros that way) | [window functions](functions-window.md) |
## 2026-10-17: year-long reads of joined and lag views fit in memory
| What changed | Measured (34M floorsheet trades over 2 years, 2 CPUs, 1.5 GB memory budget, WSL) | Read more |
|---|---|---|
| A view that joins is read a few days at a time too, when its time comes from one source and reaches the others through `ON d.time = t.time`, or the other side is a small table. Its parts are sized by what the first part held. | OTM filter over 1 / 2 years: memory full -> 4.4 / 9.1 s, about 25 MB held | [views](views.md#where-pushdown) |
| A `GROUP BY` over a view joined to a table groups the view first, then joins the groups. | broker-sector over 1 year: 9.3 s and 2.6 GB -> 0.37 s | [views](views.md#where-pushdown) |
| A `lag()` view read for one day reads only that day and each partition's last row before it, a few days per read. | broker spark, one day: 1.7 s and 2.6 GB -> 0.3-0.45 s, 22 MB held | [views](views.md#where-pushdown) |
| The row engine's `GROUP BY` keeps small states for `count`, `sum`, `avg`, `min`, `max`, and hashes its keys with the CPU's crc32 instruction. | Mkt hit over 1 year: 57 s -> 28-45 s | [views](views.md#where-pushdown) |
| A view read a few days at a time runs two parts at once on 2 or more CPUs; the rows still go on in time order. | OTM filter over 1 / 2 years (materialized views): 4.3 / 8.7 s -> 2.0 / 4.5 s; Mkt hit over 2 years: 63 s (past the 60 s statement timeout) -> 32-39 s | [views](views.md#where-pushdown) |
| Conditions and sums of numbers (comparisons, + - * /, AND, OR, CASE WHEN) are worked out without building a value object per step, and a join finds its rows in one flat table. | OTM filter's CPU over 1 year: about 5.0 s -> 4.2 s | [views](views.md#where-pushdown) |
## 2026-10-16: big answers stream over the Postgres port; long view ranges run in parts
| What changed | Measured (10M trades in 148 day partitions, `--threads 2`, `--max-result-memory 600MB`, Windows, a Go client reading every row) | Read more |
|---|---|---|
| A big plain `SELECT` over the Postgres port sends its rows while it reads them (simple query, `Execute` with or without a row limit, `Describe` first). Small answers are made and cached as before. | `SELECT * FROM fs` (10M rows): refused at `max_result_memory` -> 3.0-3.4 s, about 110 MB above the loaded table; the 34M-row floor sheet was refused at 6,609 MB | [big answers stream](postgres-protocol.md#streaming) |
| A `GROUP BY` over a window or grouping view for many days runs the view a few days at a time when the time column passes through every step. | pump-and-dump summed over 31 / 148 days: refused -> 4.1 / 18 s; Mkt hit over 10 days: memory full -> 5.9 s | [views](views.md#where-pushdown) |
| A `GROUP BY` over a view no longer runs the view's inner query whole first only to find it cannot stream it. | Accumulation/Distribution over 10 days: 60 s statement timeout -> 1.2 s | [views](views.md#where-pushdown) |
## 2026-10-10: a cold scan keeps the disk busy
| What changed | Measured (a cached daily view built over 6.2M trades in 80 day files on a Windows folder mounted into WSL, `--memory 1536`, 2 cores, files not in memory) | Read more |
|---|---|---|
| While a batch of partitions is grouped, two helper threads read the files of the next partitions into the room the memory budget has, so the disk never waits for the grouping or for the last files of a round; a budget that is full stops them and the batches make room as before. | first build of the view: 868 -> 683 ms (the second, from memory, 47 ms either way) | [memory](performance-tips.md#memory) |
| A partition file is read in as few requests as a slow mount allows: neighbouring sections in one whole-block read, the checksum table and trailer together. | read calls for the 80 files: 1,612 -> 646 | [memory](performance-tips.md#memory) |
| `CREATE VIEW`, `CREATE CACHED VIEW` and `DROP VIEW` leave the views file to the next checkpoint, as `DROP TABLE` does; the statement is in the log before it answers. | a plain `CREATE VIEW` on that folder: 93 -> 2.7 ms; the cached view's second build: 90-117 -> 47 ms | [views](views.md) |
## 2026-10-09: big scans in a full memory budget stop reading the same files again
| What changed | Measured (34M trades in 447 day partitions, `--memory 1536`, 2 cores, data on a Windows disk, after a line-protocol load) | Read more |
|---|---|---|
| A query that reads a few columns of a table bigger than the room left first cuts the partitions held whole (just written, or read for every column) down in memory to its columns, then unloads only if it must. Only when that lets all its columns fit: a bigger query, or one over a small table, leaves them whole. `KDB_NO_SLIM_SCAN=1` goes back. | `REFRESH VIEW` of a daily GROUP BY: 3.0-3.3 s -> 0.34-0.38 s (median of 15); its second build 2.6-2.8 s -> 0.39-0.47 s; files read again per run: 189-254 -> 0 | [memory](performance-tips.md#memory) |
| A number column whose saved stats say one value in the whole file (the day of a day partition) is filled from the stats instead of read. | bytes read for a cold pass of that view: 1,417 -> 1,146 MB | [memory](performance-tips.md#memory) |
## 2026-10-06: less work around each statement on the PostgreSQL port
| What changed | Measured (server CPU per statement, 2 cores, pgx sending a new text each time, result cache off; best of 3) | Read more |
|---|---|---|
| A plain statement is recognized by its first word; a connection reuses its statement record, its unnamed portal and, for a parameterized statement sent again with the same text, its parse and description (until a table, view or setting it depends on changes). | `SELECT 1`: 54 -> 36 us; a primary-key lookup: 82 -> 54 us; a one-row INSERT with parameters: 270 -> 170 us | [performance tips](performance-tips.md) |
| Rows go out as text without a string per value: times and decimals are written digit by digit. | the last 500 bars of a symbol: 413 -> 200 us; 16 clients: 4,300 -> 7,400 per second | [performance tips](performance-tips.md) |
| A query without a time range keeps each partition's time range instead of reading it from the partition's name again. | a lookup by a number over 43 day partitions: 112 -> 67 us | [zone maps](performance-tips.md#zone-maps) |
## 2026-10-02: big sorted copies fit a 3 GB memory limit
| What changed | Measured (15.3M minute bars, `--memory 1536 --memory-limit 3GB`) | Read more |
|---|---|---|
| `INSERT ... SELECT ... ORDER BY` sorts its rows in batches on disk and writes them in that order; a `DEDUP KEY` on a minute bucket (`DEDUP KEY (symbol, minute)`) keeps the copied rows as compact runs. `KDB_NO_SORTED_INSERT_STREAM=1` goes back. | 1.8M bars of 5 symbols `ORDER BY time` into `DEDUP KEY (symbol, minute)`: peak 4.9 GB and stopped -> 1.9 GB, 10 s; the same rows kept | [INSERT](insert.md) |
| A quick statement let in at the limit that needs more than its small reserve waits up to 2 s for memory to come back, then runs on. | the three `GROUP BY` checks right after that copy: `memory full` -> answered | [kdb serve](cli-serve.md#memory-limit) |
## 2026-10-02: reads take turns on a small CPU limit
| What changed | Measured (2 CPUs by quota as `docker --cpus 2`, 32 HTTP threads, 006's day-candle mix, result cache off) | Read more |
|---|---|---|
| Reads run at most as many at once as the server has CPUs (at least 2); the rest wait in line, and a read holds its turn for at most 10 ms. `--read-slots off` goes back. | 50 clients: 464 -> 2,685 requests/s; 200 clients: 500 -> 2,526; PostgreSQL prepared at 50 clients: 514 -> 2,253 | [read slots](cli-serve.md#read-slots) |
## 2026-10-02: cache tables with a memory cap
| What changed | Measured | Read more |
|---|---|---|
| `CREATE TABLE ... WITH (max_bytes = '64MB', evict = 'lru')`: once the rows take more than the cap, the rows used longest ago are deleted (also `volatile_ttl`, `random`, `volatile_lru`, `volatile_random`). `durability = 'none'` starts empty after a restart. | 100,000 rows of 100 bytes into a 1 MB table: about 1 MB left within a second; a key read every few ms is kept | [cache tables](create-table.md#cache-table) |
| The key-value store takes `CONFIG SET maxmemory`, `maxmemory-policy` (`allkeys-lru`, `volatile-ttl`, `noeviction`, ...) and `appendonly no`, and answers `MEMORY USAGE`. Keys are evicted at once, so the cap holds after every command. | | [kv memory cap](kv.md#kv-memory) |
## 2026-10-02: a Redis-like key-value store
| What changed | Measured (200 clients, one counter step per request) | Read more |
|---|---|---|
| `POST /kv` answers `GET`, `SET ... EX`, `INCR`, `EXPIRE`, `TTL`, `DEL`, `KEYS`, `MGET` and the other Redis string commands, one at a time or as a pipeline; SQL has `kv_get()`, `kv_set()`, `kv_incr()`. Keys are in memory and in the table `_kv`. | `UPDATE ... SET n = n + 1` over `/exec`: 2,100/s -> `INCR` over `/kv`: 23,000/s, every answer on disk (`--http-threads 200`); 47,000/s with `?sync=off` | [key-value store](kv.md) |
## 2026-09-25: more requests per second on 2 CPUs
| What changed | Measured (2 cores, 16 clients, result cache off) | Read more |
|---|---|---|
| A chart read (`ORDER BY time DESC LIMIT n` of plain columns) takes its rows straight from the columns as the answer. | day candles: 2,200 -> 5,000 requests/s | [performance tips](performance-tips.md) |
| `WHERE symbol = 'x'` on a `PARTITION BY ..., SYMBOL(symbol)` table finds its partitions without walking all of them. | minute candles: 4,400 -> 5,800 requests/s | [partitions](partitions-and-dedup.md) |
| `GROUP BY ... ORDER BY ... LIMIT n` picks the first groups instead of sorting all of them. | 1.2 million groups: 970 -> 920 ms | [performance tips](performance-tips.md) |
| Answers of 2 KB or more come gzip-compressed to clients on other computers that send `Accept-Encoding: gzip`. | a 500-bar chart: 31 KB -> 7 KB | [compressed answers](http-overview.md#gzip-answers) |
## 2026-09-24: fixes from the 006 test report
The NChartPro team (session 006) tested KDB as a replacement for PostgreSQL, QuestDB and Redis and listed what was missing. Most of the list went in on 2026-09-24, in five waves. The day-by-day list is in the console (Help > What's new).
**Wave 1: waits, sessions and a busy server.**
| What changed | Read more |
|---|---|
| `--http-threads`, `--max-connections`, `--lock-timeout`, and `SET lock_timeout` for one session. | [kdb serve](cli-serve.md), [transactions](transactions.md) |
| Session settings (`SET` / `current_setting`), advisory locks, `INTERVAL` columns. | [PostgreSQL functions](functions-postgres.md) |
| Exact statement timeouts, admin queue commands, a TTL that runs on a busy database. | [protection](protection.md) |
| A schema change with nothing to do locks nothing; small writers no longer wait for another table's checkpoint. | [transactions](transactions.md) |
**Wave 2: readers never wait for the wrong writer.**
| What changed | Measured | Read more |
|---|---|---|
| A `SELECT` waits only for writers of the tables it reads; a checkpoint lets readers in while it writes its files. `NOWAIT` / `SKIP LOCKED` answer at once. | a login lookup during a long CALL: 3.4 s -> 1.5 ms | [transactions](transactions.md#per-table-reads) |
| A `CALL` that only reads runs like a `SELECT`. Reader yield is on by default. | | [procedures](procedures.md#read-only-call) |
| The result cache is kept per symbol: a write of one symbol keeps the other symbols' results. `GET /api/rev` and `kdb_rev()`. | | [revisions](revisions.md) |
| One memory limit for the whole process (`--memory-limit`); small tables are unloaded last. | | [kdb serve](cli-serve.md) |
| Timers fire on time; failed deferred trigger jobs run again and raise an alert; `DROP TABLE` / `DROP PARTITION` answer at once. | DROP of 230 partitions: 1,000 -> 23 ms | [timers](timers.md), [triggers](triggers.md) |
**Wave 3: speed.**
| What changed | Measured | Read more |
|---|---|---|
| `ORDER BY time DESC LIMIT n` and the newest n buckets of a `SAMPLE BY` read the newest rows first and stop. `SAMPLE BY` itself is 2-4x faster. | 5-minute chart, 5,000 bars: 560 -> 29 ms | [ORDER BY and LIMIT](order-by-limit.md), [SAMPLE BY](sample-by.md) |
| `NOT`, `OR`, `AND` of simple conditions run in the column engine; an `OR` whose branches all have an index reads those indexes; `CASE` of sums and `ORDER BY` on the result stay on the fast path. | ghost-bar filter 189 -> 5 ms; `email = x OR phone = x` 38 -> 0.08 ms; filter page 34 -> 9 ms | [WHERE](where.md), [indexes](indexes.md) |
| `LATEST ON` over a table split by symbol reads only each symbol's newest rows; outer filters of a window query run right after the windows. | day change with `lag()`: 41 -> 3 ms | [LATEST ON](latest-on.md) |
| A join on a key or an indexed column looks up only the matching rows. | session check: 144 -> 0.13 ms | [joins](joins.md#join-speed) |
| Scans of tables bigger than the memory budget load partitions in parallel batches and keep other tables in memory. | 20M minute bars `GROUP BY`, 1.5 GB: 24 s -> 1.8 s | [performance tips](performance-tips.md) |
| Log tables: time ranges on relational tables, `EXTRACT(...) = x` as a time range, `json->>'key'` filters and text `GROUP BY` keys in the column engine. | count by level, 24 h of 2M rows: 835 -> 4 ms | [WHERE](where.md), [EXPLAIN](explain.md) |
| The result cache is 256 MB and settable per server and database; one-off quick results are kept only when asked twice; `now()` results may be cached briefly on request; a repeated `SELECT` is not parsed again; big databases open lazily. | floor-sheet day with the cache: 231 -> 19 ms | [performance tips](performance-tips.md#result-cache), [databases](databases.md#result-cache) |
| PostgreSQL-port point lookups scale with the clients; bulk loads into indexed tables are 1.6-2.8x faster. | login by email, 32 clients: 14.5k -> 115k/s; 300k logins COPY: 7.9 -> 2.8 s | [PostgreSQL port](postgres-protocol.md) |
| `SET synchronous_commit = off` for fast small commits of one session. | 5 -> 0.36 ms a commit | [performance tips](performance-tips.md#synchronous-commit) |
| The newest bars of every symbol are read newest first; a one-day `GROUP BY` with many groups uses every thread; group commit lets 2-8 writers share one disk flush. | newest 5,000 1-hour bars: ~1,000 -> 8 ms; symbol x buyer, one day: 9.5 -> 4.5 ms | [SAMPLE BY](sample-by.md), [performance tips](performance-tips.md) |
**Wave 4: PostgreSQL features.**
| What changed | Read more |
|---|---|
| `SET TIME ZONE` and `kdb serve --timezone`: `CURRENT_DATE`, `::date`, `date_trunc` and `EXTRACT` on your clock; times stay stored in UTC. | [time zones](time-and-time-zones.md) |
| `SET int_division = integer` (7 / 2 = 3); `GROUP BY ROLLUP`, `CUBE`, `GROUPING SETS` and `GROUPING()`. | [operators](operators.md), [GROUP BY](group-by.md) |
| `SAVEPOINT`, `READ COMMITTED` on request, 40001 for a busy table inside a transaction, `SET ROLE`. | [transactions](transactions.md), [users and roles](users-and-roles.md) |
| `WITH (dedup_insert = 'upsert')`: a plain `INSERT` replaces a row with the same DEDUP KEY. | [partitions and DEDUP](partitions-and-dedup.md) |
| psql `\d` and DBeaver; Unicode `lower` / `upper`, `COLLATE`; `SIMILAR TO`. | [PostgreSQL port](postgres-protocol.md), [text functions](functions-text.md) |
| JSON `?`, `?\|`, `?&`, `@>` on objects and a GIN index; `GENERATED ALWAYS AS (...) STORED`. | [JSON functions](functions-json.md), [CREATE TABLE](create-table.md) |
**Wave 5: running it safely.**
| What changed | Read more |
|---|---|
| Backups on by default; restore into a new database while the server runs, then swap; `--backup-command` copies each backup off the server. | [backups](backup-and-safety.md), [backup schedule](backup-schedule.md) |
| TLS on both ports, named API tokens that can be revoked, logins that survive a restart. | [TLS](tls.md), [API tokens](api-tokens.md) |
| Rate limits per address and per user, a time limit per user, an audit log chained by hashes. | [protection](protection.md), [audit log](audit-log.md) |
| `GET /metrics` for Prometheus, a slow-query log, trash emptied by size, disk use by kind. | [server info](http-server-info.md), [trash](trash.md) |
| `INSERT ... SELECT` copies in batches; at the memory limit the biggest statement is stopped, not the server. | [INSERT](insert.md), [kdb serve](cli-serve.md) |
## Faster queries
Queries now read less: partitions are skipped by their saved minimum and maximum, filters run as tight loops, and some totals come from saved numbers without reading any row.
| What changed | Measured | Read more |
|---|---|---|
| **Zone maps.** `WHERE qty > x`, `BETWEEN`, `IN` and `time IN` on number, date, time and `DECIMAL` columns skip every partition and block whose minimum and maximum rule it out. `EXPLAIN` says "K skipped by column stats". Off: `KDB_NO_ZONE_MAPS=1`. | an id-like column, 814,535 rows in 15 day files: 0.93 -> 0.56 ms (14 of 15 files skipped); a value no file holds: 1.66 -> 0.59 ms | [EXPLAIN](explain.md), report |
| **One symbol of a partition not in memory.** With `--memory` full, `WHERE symbol = 'X'` on a table sorted by symbol first reads only that symbol's rows of a partition file, not the whole file, and pushes nothing out. No partition file is read under the readers' shared lock any more. Off: `KDB_NO_SLICE_READS=1`. | 14.6M minute bars, 500 symbols, 2 cores, `--memory 300`, 006's chart mix: 9 -> 237 requests/s (1 client), 12 -> 378 (10 clients), 18 -> 363 (50 clients) | [performance tips](performance-tips.md#slice-reads) |
| **Totals from saved stats.** `count(*)`, `count(col)`, `sum`, `min`, `max` and `avg` with no `WHERE` (or only a time `WHERE`) are answered from the numbers saved with each file, for partitions not in memory. `EXPLAIN` says "answer from saved stats". | 4M rows in 200 day files, right after a restart: 99.7 -> 0.28 ms, 0 partitions loaded | [EXPLAIN](explain.md) |
| **LIKE, ILIKE and OR in the column engine.** `LIKE`, `ILIKE` and `NOT LIKE` on `SYMBOL` and `STRING` columns, and `col = a OR col = b`, no longer fall back to the slow row path. A `SYMBOL` pattern is checked once per name, not once per row. | 1M rows: `sym LIKE 'S6%'` 242 -> 1.2 ms; `ILIKE` 230 -> 2.2 ms; `sum(qty) WHERE sym = 'S1' OR sym = 'S2'` 151 -> 0.30 ms | [WHERE](where.md), [performance tips](performance-tips.md) |
| **Branch-free filters.** Every compare on `LONG` and `DOUBLE`, and `BETWEEN`, runs as a tight loop over 8 rows at a time. | 1M random rows: `x > L` 4.68 -> 0.67 ms; `y > L` (DOUBLE) 7.03 -> 0.62 ms; `b BETWEEN` 8.77 -> 0.84 ms | [performance tips](performance-tips.md) |
| **The row path reads only the columns a query uses.** Queries the column engine cannot run no longer build all columns of every row. Off: `KDB_NO_COLUMN_MASK=1`. | a 21-column table, 1M rows: `count(*) WHERE a * 1 > X` 460 -> 210 ms; `GROUP BY` 549 -> 242 ms; `SELECT *` not slower (465 -> 436 ms) | [EXPLAIN](explain.md) ("build 2 of 3 stored columns") |
| **Warm at open.** Indexes are built on a background thread right after a database opens, so the first query after a restart finds them ready. `--warm-indexes on\|off`, on by default. | first request after a restart, 200k posts: author lookup 89.7 -> 1.5 ms; common-word search count 774 -> 3.1 ms | [kdb serve](cli-serve.md), [indexes](indexes.md), report |
| **Pinned queries.** An admin pins a query with `POST /api/pins`; its answer stays in the result cache, is never pushed out, and is made again after writes. | a pinned dashboard query answers from memory after every restart | [pinned queries](pinned-queries.md) |
| **GROUP BY with many groups.** Tens of thousands of groups, `buyer = seller`, `date_trunc` keys, `count(DISTINCT)` and `CASE` inside `sum` run in the column engine. | 30 days of trades by symbol and broker: 84 -> 14 ms | [GROUP BY](group-by.md), [performance tips](performance-tips.md#many-groups), report |
| **GROUP BY over UNION ALL** groups each part by itself first. | buy side + sell side in one query: 1,750 -> 29 ms | [UNION](union-subqueries.md), [performance tips](performance-tips.md#big-group-by) |
| **Integer hash join.** A join on one equality of numbers no longer makes a text key for every row. | | [joins](joins.md) |
| **Procedure parameters prune partitions.** `WHERE date = d` inside a procedure reads only that day. | 1 of 1,060 partitions read; 16 ms cold, 0.2 ms warm | [procedures](procedures.md), report |
**Example: See it in EXPLAIN: a day skipped by its column stats**
```sql
CREATE TABLE trades (time TIMESTAMP, symbol SYMBOL, qty LONG) TIMESERIES(time) PARTITION BY DAY;
INSERT INTO trades VALUES
('2026-09-21T10:00:00Z', 'CITY', 100), ('2026-09-21T11:00:00Z', 'COAST', 250),
('2026-09-22T10:00:00Z', 'CITY', 4000), ('2026-09-22T11:00:00Z', 'COAST', 5200);
-- No row of 2026-09-21 has qty over 1000, so that day is never read.
EXPLAIN SELECT count(*) FROM trades WHERE qty > 1000;
```
Result:
`CREATE TABLE trades (`
```text
table 'trades' created
```
`INSERT INTO trades VALUES ('2026-09-21T10:00:00Z', 'CITY', 100), ('2026-09-21T11:00:00Z', 'COAST', 2`
```text
4 row(s) inserted
```
`EXPLAIN SELECT count(*) FROM trades WHERE qty > 1000`
| plan (STRING) |
|---|
| scan table trades (2 of 2 partitions, about 4 rows) |
| build 2 of 3 stored column(s) per row: time, qty |
| filter while scanning: 1 condition(s) |
| group by 0 key(s), 1 aggregate(s) |
| engine: column |
(5 rows)
## Faster loads
Big loads go through the fast columnar path in more cases, and a trigger on one table no longer slows the others.
| What changed | Measured | Read more |
|---|---|---|
| **Line protocol in one pass (2026-10-06).** A line shaped like the one before it is read in one pass, and its values go straight into the table columns; a line that is different in any way is read as before, with the same errors. | 1 million trades lines in 100,000-line batches, 2 cores: 1.55M -> 2.2M rows/s, half the server CPU; 2 million candles 1.7M -> 2.4M rows/s | [line protocol](line-protocol.md) |
| **Tables without a DEDUP KEY are written as runs.** A trade list, where two trades in one second are both real, now takes the same fast path as a candle table. | 814,535 real trades, 20,000 rows a request: 438k -> 918k rows/s; QuestDB 678k rows/s on the same PC (KDB 35% faster, was 36% slower) | [line protocol](line-protocol.md), report |
| **Per-table trigger gate.** An immediate trigger on one table no longer sends every write of the database down the row-by-row path. | a 20,000-row write into a table with no trigger, while another table has one: 34 -> 8 ms (the same as no trigger at all) | [triggers](triggers.md) |
| **Deferred triggers.** `CREATE TRIGGER ... AFTER INSERT ON t DEFERRED` runs the trigger body after the load returns, as a background job. | a 20,000-row write into a table with a heavy trigger: 103 -> 9 ms once the trigger is deferred | [triggers](triggers.md#deferred) |
| **Batch duplicate check.** A `/write` batch into a table whose `SORT BY` differs from its `DEDUP KEY` is checked as one batch and stays on the fast path. | | [partitions and DEDUP KEY](partitions-and-dedup.md) |
| **`dedup=skip`** on `POST /write` and `POST /imp` loads rows you know are new without the duplicate check; `DEDUP TABLE` repairs duplicates afterwards. | | [dedup=skip](partitions-and-dedup.md#dedup-skip), [DEDUP TABLE](partitions-and-dedup.md#dedup-table) |
| **`PARTITION BY MONTH, SYMBOL(col)`** (also `YEAR` or `NONE`) puts each symbol of a new time-series table in its own partitions, so `col = 'X'` and `col IN (...)` read only those. | | [partitions and DEDUP KEY](partitions-and-dedup.md#partition-by) |
| **Packed partitions (2026-09-25).** `ALTER TABLE t SET (compression = on)` stores the sealed partitions of a table packed; `COMPRESS TABLE t` shows how far the background pass is. Off by default. | a floorsheet-shaped table of 3 million rows: 76.1 -> 15.5 bytes a row on disk; the first GROUP BY after a start 433 -> 368 ms | [compression](partitions-and-dedup.md#compression) |
## Search
Searches of 200,000 news articles now beat PostgreSQL 18 with a GIN index on every measured query but one, where the two tie.
The one tie is a word plus a symbol and a month (0.54 vs 0.48 ms). The articles hold 378 MB of text.
| What changed | Measured (KDB vs PostgreSQL 18) | Read more |
|---|---|---|
| **Counts come from the index.** `count(*)` of a search no longer fetches every found row, and a search never falls back to reading every text. | common word: 3.6 ms vs 250 ms (69x); mid word: 0.66 ms vs 82 ms (124x) | [full-text search](full-text-search.md), report |
| **Word positions in the index.** A phrase like `"hydro power"` is answered from the index. | 2,347 -> 9.7 ms (PostgreSQL 295 ms) | [search text](full-text-search.md#search-syntax) |
| **LIMIT goes into the index.** `ORDER BY id LIMIT 20` on a search stops after 20 rows. | forum search: 7.16 -> 0.265 ms (PostgreSQL 0.843 ms) | [full-text search](full-text-search.md) |
| **The cheaper index goes first.** A search plus a plain filter (a symbol, a month) starts with whichever index finds fewer rows, and checks the other condition on those rows only. | | [EXPLAIN](explain.md) |
| **Ranked search** (`ORDER BY search_rank(...) LIMIT 20`) on the same 200,000 articles. | top 20: 5.5 to 16.9 ms vs 364 to 408 ms (23x to 66x); 8 clients: 348/s vs 23/s | [search_rank](full-text-search.md#search-rank) |
| **Readers locked against writers.** A search no longer reads the index while a write changes it, a likely cause of a silent exit under many searches. | | [full-text search](full-text-search.md) |
## Writes, locks and transactions
Writers of different tables now run at the same time.
| What changed | Measured | Read more |
|---|---|---|
| **Table-level write locks.** A write locks only the tables it changes, not the whole database. Deadlocks between two writers are found and one is told to retry. | 8 clients doing bank transfers: 604 -> 1,981 transfers/s; PostgreSQL 890/s in the same minutes | [transactions](transactions.md#one-writer), report |
| **Group commit.** Several transactions that finish together share one disk flush. | (part of the number above) | [transactions](transactions.md) |
| **LOCK TABLE, pg_locks, pg_blocking_pids.** `LOCK TABLE ... IN MODE [NOWAIT]` with PostgreSQL's eight modes; `pg_locks` shows holders and waiters with `pg_stat_activity`'s pids; a deadlock names both pids (`40P01`); `log_lock_waits` logs a wait over `deadlock_timeout`. | the same speed while no `LOCK TABLE` is held (before and after, side by side): taking and freeing one table lock 81-84 ns and 83-84 ns; a one-row `UPDATE` 16.8-17.0 us and 16.5-17.3 us; a primary-key `SELECT` 6.3-6.8 us and 6.2-6.4 us | [LOCK TABLE](transactions.md#lock-table) |
| **One disk wait per pipeline.** On the PostgreSQL port, several `Execute`s before one `Sync` wait for the disk once, at the `Sync`. | counter updates, batches of 32, 1 connection: 211 -> 4,277 a second | [performance tips](performance-tips.md#one-wait) |
| **Reader yield.** With `--reader-yield on` (or `KDB_READER_YIELD=on`) reads do not wait for a long trigger or `CALL`; they read the last committed rows. | | [kdb serve](cli-serve.md) |
| **Index lookups on a snapshot** (2026-10-02). With snapshot reads on, a lookup by a plain or expression index on a table a long `CALL` writes uses the index on the snapshot, at any table size, instead of waiting (over 1,000,000 rows) or reading every row. | 1.1 million rows, lookups during a long `CALL`: p50 0.12 ms, p99 3.3 ms | [snapshot reads](transactions.md#snapshot-reads) |
| **Opening a big database** no longer blocks the database list, `/api/stats` or other databases. | a 9.5 GB database, 124 s open: `/api/stats` answered in 100 to 110 ms all the way through | [many databases](cli-serve.md#many-databases) |
| **Shared memory budget.** Idle databases give memory back first; writers keep a floor. | | [performance tips](performance-tips.md#memory) |
## Protection
New checks for a full disk, junk input and slow patterns, all with tests.
| What changed | Measured | Read more |
|---|---|---|
| **A full disk.** Committed rows stay intact, the client gets HTTP **507** `{"code":"disk full"}` (SQLSTATE **53100** on the PostgreSQL port), and writes go on when space comes back, with no restart. Five faults fixed on the way. | 24 rounds x 3 log modes x 2 places for the fault, all pass | [limits](limits.md#disk-memory), report |
| **Junk text.** NUL bytes, broken UTF-8, emoji, 1 MB texts and broken search syntax through every text function and the search. | 12,132 junk statements: no crash, no hang; every junk row still found by its clean word after a restart | report |
| **The statement timeout stops scans sooner.** It is now checked every 1,024 rows, also for rows not yet saved to files. | a 300 ms timeout ended a 187 s regexp scan of 200,000 rows in 0.9 s | [statement timeout](protection.md#statement-timeout) |
| **Encryption at rest** (2026-09-26). `CREATE DATABASE x WITH (encryption = on)` encrypts every file, log record and backup of that database with AES-256-GCM (ChaCha20-Poly1305 without AES instructions). Off by default; the master key comes from `KDB_MASTER_KEY` or a key file, never the data folder. | scans of loaded data +3%; the first scan after opening +18%, short of the 10% goal; files +2.3% | [encryption at rest](encryption.md) |
| **Timers need admin and are audited.** | | [timers](timers.md) |
| **The memory limit holds.** It counts what the process holds (resident), not only the heap; quick statements run in a small reserve at the limit; the result cache is dropped before the limit refuses work; a big `INSERT ... SELECT` into a keyed table puts its rows in as it goes; `SELECT` of another database runs there. | 400,000 rows into a DEDUP `YEAR, SYMBOL` table: ~1,100 -> ~150-280 bytes a row, 1.7 s -> 0.9 s; `count(*)` of 400,000 rows in another database: 486 ms and 363 MB -> 0.2 ms | [kdb serve](cli-serve.md#memory-limit) |
| **All 52 test programs under ASan and UBSan** (tools that catch memory mistakes). | one finding, fixed (an empty text passed to `memcpy` in the password hash code) | [test report](test-report.md) |
## Running the server
Less disk, faster checkpoints, backups under load, and the help in the terminal.
| What changed | Measured | Read more |
|---|---|---|
| **Old files leave the trash while the server runs.** The idle thread empties trash older than an hour, every minute (it used to wait for a restart). `PURGE TRASH` and `PURGE VERSIONS [KEEP n]` free space now; `GET /api/disk` shows what each part takes. | `PURGE TRASH` freed 39.6 GB in 49 s on a real data folder | [PURGE TRASH](databases.md#purge-trash), [PURGE VERSIONS](databases.md#purge-versions), report |
| **Keep data for a time.** `DROP PARTITION ... OLDER THAN`, a table TTL, and monthly timers drop old rows on a schedule. | | [Keep data page](console-keep-data.md), [timers](timers.md), [DROP PARTITION](partitions-and-dedup.md#drop-partition) |
| **Old log kept per database.** `ALTER DATABASE ... SET WAL_ARCHIVE` keeps the old write-ahead log for point-in-time restore. | | [WAL_ARCHIVE](databases.md#wal-archive) |
| **Leftover keys of dropped tables are swept** at open and at every checkpoint. `kdb info` shows the count. | 300 partitions, one table dropped: manifest 1,128 -> 573 KB; checkpoint 24.6 -> 14.7 ms | [kdb info](cli-maintenance.md) |
| **Backup under load.** | a 2.1 GB backup made and verified in 7 s while 16 query lanes and 2 writers ran: 0 errors, 0 timeouts | [backup](backup-and-safety.md) |
| **30-minute soak** on the table-locks build. | 19.9M line-protocol rows + 1.36M SQL rows, 64,637 queries, 14 checkpoints, 0 errors, every row accounted for | [test report](test-report.md), report |
| **Memory given back** to the system on Linux (glibc) builds after big queries. | | [performance tips](performance-tips.md#memory) |
| **`kdb help [ \| tutorial]`** prints the help topics and the tutorial in the terminal. | | [the kdb program](cli-overview.md) |
## SQL and the HTTP API
New SQL for queries across databases and procedures, and two HTTP options that make answers smaller.
| What changed | Read more |
|---|---|
| `/exec?format=columns` answers one array per column, the shape a chart wants. | [format=columns](http-exec.md#format) |
| `/exec` sends an `ETag`; a client that sends it back in `If-None-Match` gets **304** and no body when nothing changed. | [ETag](http-exec.md#etag) |
| Queries across databases: `otherdb.table` in any `SELECT`. | [queries across databases](cross-database-queries.md) |
| `EXECUTE` runs SQL made at run time inside a procedure. | [procedure language](procedure-language.md) |
| `CREATE MATERIALIZED VIEW`, refreshed partition by partition. Since 2026-09-26 also QuestDB's `SAMPLE BY` form, refreshed bucket by bucket (1,000,000 ticks: a one-tick refresh about 4 ms, was a day's partition), `REFRESH ... CONCURRENTLY / FULL / INCREMENTAL / RANGE`, `materialized_views()` and `views()`. | [materialized views](views.md#materialized-view) |
| `(symbol, time) IN (('CITY', '2026-09-10'), ...)` on keys, and time zones for data stored at local midnight. | [WHERE](where.md), [time zones](time-and-time-zones.md) |
| `SELECT DISTINCT date` and `min`/`max`/`count(DISTINCT)` of it answered from the partition list, without reading rows. | [EXPLAIN](explain.md) |
| `TRUNCATE ALL TABLES`. | [TRUNCATE ALL TABLES](databases.md#truncate-all-tables) |
| `RIGHT JOIN`, `FULL JOIN`, `JOIN ... USING (columns)` (PostgreSQL), `LT JOIN` and `SPLICE JOIN` (QuestDB), `ON (symbol)` keys for time joins (2026-09-25). 1M rows: `RIGHT` / `FULL JOIN` about as fast as `LEFT JOIN` (0.41 s), `LT JOIN` as `ASOF JOIN` (0.5 s). | [joins](joins.md#right-join) |
| 2026-09-26: QuestDB time filters: `time IN '2026-09-24;1d'`, repeated windows `'...T05:15;1h;1d;5'`, `IN today()` / `interval(a, b)`, `LIMIT -n` (the newest n rows), `LATEST BY`, `DECLARE @x := ...`, and `timestamp_ceil`, `days_in_month`, `timestamp_sequence` and more. Only the partitions of the windows are read. | [interval lists](where.md#interval-lists), [LIMIT -n](order-by-limit.md#limit), [timestamp_ceil](functions-date-time.md#timestamp_ceil) |
| 2026-09-25: `first()` / `last()` keep a NULL first / last row, as in QuestDB; `first_not_null()` / `last_not_null()` skip it. | [first](functions-aggregate.md#first) |
| 2026-09-25: `encode(sha256(x), 'hex')` gives the 64-character hash as in PostgreSQL; `sha224/384/512`, `digest`, `convert_to`, `convert_from`, `round_half_even`. | [sha256](functions-security.md#sha256), [convert_to](functions-bytes.md#convert_to) |
| 2026-09-26: PostgreSQL's pgcrypto: `pgp_sym_encrypt` / `pgp_sym_decrypt` (OpenPGP, opens in PostgreSQL and GnuPG), `encrypt` / `decrypt`, `crypt` / `gen_salt` (bcrypt, MD5, DES), `gen_random_bytes`, `armor` / `dearmor`; QuestDB's `base64`. 1M rows: `digest(x, 'sha1')` 0.76 -> 0.44 s, `md5(x)` 0.52 -> 0.33 s. | [pgcrypto](functions-security.md#pgcrypto) |
| 2026-09-25: `jsonb - 'key'`, `#-`, and JSON to rows: `jsonb_each`, `jsonb_array_elements`, `json_to_recordset`, `jsonb_populate_recordset`, ... | [JSON to rows](functions-json.md#json-rows) |
| 2026-09-25: a NaN from a calculation is stored as NaN, not NULL; `/exec?nan=string` sends NaN and Infinity as text. | [DOUBLE](column-types.md#double), [nan=string](http-exec.md#nan) |
| 2026-10-02: in a time-series table a NaN is stored as NULL, as in QuestDB, so `open IS NULL OR open <= 0` finds it (a CHECK still refuses it); QuestDB's `ksum` / `nsum` are `sum`, whose DOUBLE total is compensated (exactly 55128735 where QuestDB's plain `sum` gives 55128734.99999999). | [DOUBLE](column-types.md#double), [sum](functions-aggregate.md#sum) |
| 2026-09-26: PostgreSQL's JSON path language: `jsonb_path_query`, `jsonb_path_match`, `@?`, `@@`, filters, sums and methods; `jsonb_pretty`, `jsonb_strip_nulls`, `jsonb_set_lax`, `jsonb_insert`, `jsonb_object`, `jsonb_agg`, `json(b)_object_agg`, `array_to_json`. 1M rows: `jsonb_path_exists` 0.82 -> 0.34 s, `doc @? '$.n ? (@ > 50)'` 0.13 s. | [JSON path](functions-json.md#jsonpath) |
| 2026-09-26: QuestDB's `query_activity()`, `CANCEL QUERY ` and `cancel_query(id)`; `GET /api/sessions` and `DELETE /api/sessions/`; `pg_terminate_backend` sends `57P01`; `pg_sleep`, `idle_session_timeout` (`57P05`), `client_connection_check_interval` and TCP keepalive on every connection. | [query_activity and CANCEL QUERY](server-activity.md#query-activity), [idle and vanished clients](server-activity.md#idle-clients) |
| `pg_stat_activity` lists every connection, HTTP session and running request (it was empty); `pg_cancel_backend` and `pg_terminate_backend` stop them; `pg_stat_statements` keeps calls, times and rows per statement shape, for a few hundred nanoseconds per statement. | [pg_stat_activity and pg_stat_statements](server-activity.md) |
| `EXPLAIN ANALYZE` runs a `SELECT` and shows its real rows, the time of each part, the engine, the partitions read and the memory held; `EXPLAIN (FORMAT JSON)` gives the plan as JSON. | [EXPLAIN ANALYZE](explain.md#explain-analyze) |
| `LISTEN`, `NOTIFY` and `pg_notify()` as in PostgreSQL, and `GET /listen` (server-sent events) for web pages; triggers can send them. | [LISTEN and NOTIFY](listen-notify.md) |
| Rows that expire one by one: `CREATE TABLE ... WITH (ttl_column = expires_at)`. | [rows that expire](create-table.md#row-ttl) |
| 2026-09-26: a table as it was, `SELECT ... FROM t AS OF TIMESTAMP '...'` or `AS OF LSN n` (undo a bad import with one `INSERT OR REPLACE ... SELECT`), and `CREATE DATABASE test BRANCH OF live [AS OF ...]`: a writable copy that shares the data files (no copying, no extra disk). | [time travel and branches](as-of-and-branches.md) |
## Console, help and packages
A new console layout, install packages, and the console, help and skill as plain folders next to the program.
| What changed | Read more |
|---|---|
| A redesigned console: timers page, activity log (inserts and trigger time), a Tools panel (`PURGE TRASH`, `TRUNCATE ALL TABLES`), a Delete button for databases, a Keep data page and a Save disk view. | [web console](web-console.md), [Keep data page](console-keep-data.md), [Monitor page](console-monitor.md) |
| Comments and suggestions from the Help menu. | [feedback](feedback.md) |
| Install packages for Windows and Linux, and a Docker image. | [install packages](install-packages.md) |
| Nothing is built into the program: the console, the help and the Claude skill are the folders `web/`, `help/` and `skill/` next to `kdb`. A console or help change needs no new program. | [where kdb finds them](install-packages.md#folders) |
| A skill file that teaches an AI assistant in another project how to use KDB (`kdb skill install`, `GET /skill.md`). | [the Claude skill](install-packages.md#skill) |
---
# Install packages and the Claude skill
> The Windows and Linux install packages, the Docker image, and the skill file that teaches an AI assistant in another project how to use KDB.
- Category: Getting started
- Keywords: install, installer, package, zip, tar.gz, install.sh, install.ps1, PATH, systemd, service, Docker, image, container, docker stop, docker rm, stop-timeout, restart unless-stopped, HEALTHCHECK, healthy, connection refused, retry, 503, Retry-After, KDB\_DRAIN\_TIMEOUT, last shutdown, not clean, uninstall, Claude, AI, skill, kdb skill, kdb skill install, --dir, --url, --home, KDB\_URL, token file, 0600, /skill.md, agent, assistant, KDB\_TOKEN, KDB\_MEMORY, KDB\_MEMORY\_LIMIT, KDB\_READER\_YIELD, KDB\_SNAPSHOT\_READS, KDB\_CORE\_DUMPS, KDB\_CORE\_KEEP, core dump, crash, gdb, kdb.debug, KDB\_DATA\_DIR, KDB\_CONFIG, KDB\_BACKUP\_SCHEDULE, KDB\_BACKUP\_COMMAND, GLIBC\_TUNABLES, kdb.conf, volume
- See also: [Quick start](quick-start.md), [kdb serve](cli-serve.md), [Web console](web-console.md), [HTTP API overview](http-overview.md), [Keeping kdb serve safe](server-security.md)
KDB ships as one program and three folders of plain files next to it: the web console, the help pages and the Claude skill. There is nothing else to install - no runtime, no libraries, no service to configure first. There are three ways to get it onto a machine.
| Way | You get | Best for |
|---|---|---|
| The Windows package | `kdb--windows-x64.zip` | a desktop or a Windows server |
| The Linux package | `kdb--linux-x64.tar.gz`, built static so it runs on any distribution | a VPS or a Linux desktop |
| The Docker image | a small Alpine image with the program and its folders | a server you would rather not install anything on |
An unpacked package looks like this:
```text
kdb---x64/
kdb.exe (Windows) or kdb (Linux) the program - nothing else is inside it
web/ the web console
help/ the help pages (missing when the package was made without them)
skill/skill.md the Claude skill
install.ps1 or install.sh, kdb.conf.example, README.md
```
## Windows
Unpack the zip and run the installer from the folder it made:
```powershell
powershell -ExecutionPolicy Bypass -File .\install.ps1
```
It copies `kdb.exe` and its `web`, `help` and `skill` folders into `%LOCALAPPDATA%\Programs\kdb`, adds that folder to **your** PATH (no administrator rights needed) and writes the [Claude skill](install-packages.md#skill). Add `-Token my-secret` to save your token beside the skill, in a file only you can read.
| Option | Meaning | Default |
|---|---|---|
| `-Prefix ` | where the program goes | `%LOCALAPPDATA%\Programs\kdb` |
| `-Token ` | the token, saved in the file `token` beside the skill | `$env:KDB_TOKEN` |
| `-Data ` | the data folder it suggests | `%LOCALAPPDATA%\kdb\data` |
| `-NoPath` | do not touch PATH | - |
| `-NoSkill` | do not write the skill | - |
Uninstalling is deleting that folder and taking it off your PATH. Nothing is registered with Windows.
## Linux
Unpack the tar.gz and run the installer from the folder it made:
```bash
./install.sh # just you: ~/.local/bin, folders in ~/.local/share/kdb
sudo ./install.sh --system # everyone: /usr/local/bin, folders in /usr/local/share/kdb
sudo ./install.sh --system --service --token my-secret # ...and start it at boot
```
| Option | Meaning | Default |
|---|---|---|
| `--system` | install into `/usr/local` instead of `~/.local` | - |
| `--prefix ` | install somewhere else | `~/.local` |
| `--service` | write a systemd unit and start it (needs root and `--token`) | - |
| `--token ` | the token for the service and the skill | `$KDB_TOKEN` |
| `--data ` | the data folder | `/var/lib/kdb` or `~/.local/share/kdb` |
With `--service` the server runs as its own `kdb` user, is restarted if it stops, and can write to its data folder and nowhere else. The unit file holds the token, so it is readable only by root.
```bash
systemctl status kdb
journalctl -u kdb -f
```
## Where kdb finds the console, the help and the skill
The web console, the help pages and the Claude skill are files, so a new console or help needs the files copied, not a new program. `kdb` looks for each folder in this order and uses the first one that has its files in it (`index.html`, or `skill.md`):
| Order | Web console | Help pages | Claude skill |
|---|---|---|---|
| 1. `kdb.conf` | `"web_dir"` | `"help_dir"` | `"skill_dir"` |
| 2. environment | `KDB_WEB_DIR` | `KDB_HELP_DIR` | `KDB_SKILL_DIR` |
| 3. next to the program | `web/` | `help/` | `skill/` |
| 4. `share/kdb` beside the program folder | `../share/kdb/web` | `../share/kdb/help` | `../share/kdb/skill` |
| 5. a source checkout (`build/bin/kdb`) | `web/console` | `docs/help/site` | `.claude/skills/kdb` |
- The console's **Settings** page shows the folders in use and what chose each one. They are read-only there.
- Files are read when they are asked for, so copying a new help build into `help/` shows at the next page load, without a restart.
- A folder that is missing does not stop the server. The HTTP API works, `http://127.0.0.1:9900/` says the console is not installed and lists the folders it looked in, and `/help/` says how to build the help.
A `kdb.conf` that names all three:
```json
{
"data_dir": "E:/kdb-data",
"web_dir": "E:/kdb/web",
"help_dir": "E:/kdb/help",
"skill_dir": "E:/kdb/skill"
}
```
## Docker
```bash
docker run -d --name kdb -p 9900:9900 -p 8832:8832 \
-e KDB_TOKEN= -v kdb-data:/data kdb
```
The data lives in the named volume `kdb-data`, so removing and recreating the container does not lose it. The server refuses to start without `KDB_TOKEN`: the image listens on all addresses, so a published port can reach it, and KDB does not do that without a secret.
The image holds `/usr/local/bin/kdb` and its folders in `/usr/local/share/kdb/web`, `help` and `skill` (named by `KDB_WEB_DIR`, `KDB_HELP_DIR` and `KDB_SKILL_DIR`). The image build only compiles `kdb` and copies those folders from the source tree, so run the help build first when the help pages should be in it.
- Add `--restart unless-stopped` so the server comes back by itself after Docker or the computer restarts.
- The image has a health check: `docker ps` shows `healthy` once the HTTP port answers `GET /health`. While the server is stopping, `/health` answers 503.
- Anything after the image name is added to `kdb serve`, for example `... kdb --threads 4`.
The settings of the image, all given with `-e NAME=value`:
| Variable | Meaning | Default in the image |
|---|---|---|
| `KDB_TOKEN` | the secret every request sends as `Authorization: Bearer `. The start script passes it as `--token`; the program itself reads no `KDB_TOKEN`. | none: required |
| `KDB_MEMORY` | `--memory`, in MB (passed as the flag) | `256` |
| `KDB_MEMORY_LIMIT` | one limit for everything the server holds ([memory limit](cli-serve.md#memory-limit)), like `3GB` | 85% of the container's memory limit, else of Docker's memory |
| `KDB_DATA_DIR` | the data folder | `/data` |
| `KDB_CONFIG` | the settings file (passed as `--config`) | `/data/kdb.conf` |
| `KDB_DRAIN_TIMEOUT` | how long running requests may finish on a stop | `5s` |
| `KDB_READER_YIELD` | `on`: reads do not wait for a long trigger or transaction ([--reader-yield](cli-serve.md#kdb-serve)). `off`: they wait. | `on` |
| `KDB_SNAPSHOT_READS` | `on`: a read of a table being written reads a snapshot of the committed rows and does not wait ([--snapshot-reads](cli-serve.md#kdb-serve)) | `off` |
| `KDB_PER_TABLE_READS` | `off`: every read waits for every writer, as before ([--per-table-reads](cli-serve.md#kdb-serve)) | `on` |
| `KDB_READ_ONLY_CALLS` | `off`: every `CALL` is a transaction, also one that only reads ([--read-only-calls](cli-serve.md#kdb-serve)) | `on` |
| `KDB_CORE_DUMPS` | `on`: a crash leaves a core dump in `/data/_logs/cores` (kdb runs from that folder). At the next start the dump is renamed with its time. `off`: no dumps. The unstripped program for reading one is `/usr/local/lib/kdb/kdb.debug` (`gdb kdb.debug core.`). | `on` |
| `KDB_CORE_KEEP` | how many old core dumps are kept; a dump is about as big as the server's memory | `2` |
| every other `KDB_*` variable of `kdb serve` | as listed in [every option](cli-serve.md#all-options), for example `KDB_HTTP_THREADS`, `KDB_TIMER_WORKERS`, `KDB_CACHE_MB`, `KDB_TIMEZONE` | the program's defaults |
| `GLIBC_TUNABLES` | for example `glibc.malloc.arena_max=8`: how many memory arenas the C library keeps (fewer than the query threads makes concurrent queries wait for each other's allocations; 2 arenas halved the request rate of 8 clients) (see `allocator_arena_max` in [/api/stats](http-server-info.md#api-stats)). With `trim_threshold` set, KDB puts glibc's mmap line at 32 MB, where glibc would move it itself (`allocator_mmap_threshold`) | KDB chooses |
A fuller run line, as a long-running server uses it:
```bash
docker run -d --name kdb --restart unless-stopped --stop-timeout 60 \
-p 9900:9900 -p 8832:8832 \
-e KDB_TOKEN= -e KDB_MEMORY=1536 -e KDB_READER_YIELD=on \
-e GLIBC_TUNABLES=glibc.malloc.arena_max=8:glibc.malloc.trim_threshold=67108864 \
-v kdb-data:/data kdb
```
On Windows, `-v "D:\kdb_data:/data"` puts the data in a Windows folder instead of a named volume.
### The data folder and kdb.conf in the container
- The image passes the data folder as the environment variable `KDB_DATA_DIR`, **not** as `--data-dir`. A command-line flag always wins over what the console's [Settings page](console-settings.md) saves, so a flag would make that page read-only. With the variable, `/data` is the default and an admin can still choose another folder in the console.
- `kdb.conf` is `/data/kdb.conf`, inside the volume. A setting saved on the Settings page survives `docker rm` and a new image.
- A data folder chosen on the Settings page must be under a mounted volume too. Anywhere else is in the container's own layer and is thrown away with the container.
- Backups go to `/data/_backups`, inside the volume, with no setting. Turn on a schedule with `-e KDB_BACKUP_SCHEDULE="daily 02:00"` (or the Settings page), and copy each backup off the machine with `-e KDB_BACKUP_COMMAND="..."` - the tool it runs (rclone, aws) must be in the image ([backup schedule](backup-schedule.md#backup-command)).
### Stopping and restarting the container
| Command | What KDB gets | Next start says |
|---|---|---|
| `docker stop kdb`, container run **with** `--stop-timeout 60` | SIGTERM, then a kill after 60 s | `last shutdown: clean` |
| `docker stop -t 60 kdb` | SIGTERM, then a kill after 60 s | `last shutdown: clean` |
| `docker stop kdb`, container run **without** `--stop-timeout` | SIGTERM, then a kill after Docker's default wait - seen as short as **1 s** | `clean` only if nothing was running |
| `docker rm -f kdb`, `docker kill kdb` | a kill at once | `last shutdown: not clean (crash or kill)` |
An image cannot choose how long Docker waits, so give the container `--stop-timeout` when you create it. Without it, a stop while a request runs, or while a big database closes, ends in a kill.
On SIGTERM the server:
1. keeps the port open and lets requests that are already running finish, up to `KDB_DRAIN_TIMEOUT` (5 s in the image);
2. answers every new request with **503** and `Retry-After: 2`;
3. cancels what still runs (a writer that waits for a busy table stops waiting too), closes every database side by side and writes the clean-shutdown mark.
A kill is safe too - every confirmed write is in the log - but the next start has to replay the log. So stop the container before you remove it:
```bash
docker stop -t 60 kdb && docker rm kdb
docker run -d --name kdb --restart unless-stopped --stop-timeout 60 \
-e KDB_DRAIN_TIMEOUT=30s -e KDB_TOKEN= \
-p 9900:9900 -p 8832:8832 -v kdb-data:/data kdb
```
- `--stop-timeout 60` makes a plain `docker stop` (and Docker's own restart) wait 60 s. Keep `KDB_DRAIN_TIMEOUT` well below it, so closing the databases still fits.
- The server log shows `databases closed cleanly (N ms)`. If N gets close to the stop timeout, give it more.
- With Docker Compose the same is `stop_grace_period: 60s` on the service.
### What a client should do
While the container is stopped and started again there is a short gap, about 2 s on a laptop.
| The client sees | Meaning | Do |
|---|---|---|
| HTTP **503** with `Retry-After` | the server is stopping; the request was not run | wait that many seconds, send it again |
| **connection refused** | the server is not listening yet (or any more) | wait a second, try again |
| the connection closed before a reply | the request was cut off | send it again |
- A write that was cut off, or answered 503, is rolled back as a whole: no row of it is saved. Sending the same `/write`, `/imp` or `/exec` again is safe.
- A request that was already running when the stop began gets its real answer, then the connection closes. Open a new one for the next request.
- Retry refused connections, not only HTTP errors. A loader that gives up on the first refused connection stops at every restart.
## The Claude skill
Claude Code, or any other AI assistant, working in **some other project** has no idea KDB exists. It will not guess the port, the token, the shape of `/exec` or where the help is. A *skill file* tells it, once, and every project on the machine benefits.
The installers write it for you. To do it by hand, or again after the address or token changed:
Syntax:
```sql
kdb skill
kdb skill install [--dir ] [--url ] [--token ] [--home ]
```
```bash
kdb skill install # writes ~/.claude/skills/kdb/SKILL.md
kdb skill install --token --home /srv/kdb-source
kdb skill # print it instead, to pipe somewhere
```
| Option | Meaning | Default |
|---|---|---|
| `--dir ` | where to write `SKILL.md` | `~/.claude/skills/kdb` |
| `--url ` | the address the assistant should use | `$KDB_URL`, else `http://localhost:9900` |
| `--token ` | the token, saved in the file `token` beside `SKILL.md` (see below) | `$KDB_TOKEN` |
| `--home ` | a source checkout, so the assistant can read `docs/help/topics/` | - |
**Command line: Install into a folder of your choice, with another address**
```bash
kdb skill install --dir my-skills/kdb --url http://kdb.example.lan:9900
```
Output:
```text
wrote my-skills/kdb/SKILL.md
Claude Code picks it up in any project the next time it starts.
```
**The token is never written into `SKILL.md`.** An assistant may quote that file, or you may copy it somewhere, so it only says where the token is. The token goes into its own file, `token`, in the same folder:
| | `SKILL.md` | `token` |
|---|---|---|
| holds | the skill, the address, the paths, **where** the token file is | the token, on one line |
| who can read it | like any of your files | only you: mode `0600` on Linux and macOS, an owner-only ACL on Windows |
Run without a token, `kdb skill install` leaves an existing `token` file alone and still points to it. A `SKILL.md` written by an older `kdb` held the token in clear: run `kdb skill install` again to replace it.
`--dir` is **refused** when it is, or is inside, a folder the server hands out without a login: the skill folder (`GET /skill.md`), the console folder or the help folder, wherever `kdb` looks for them ([the folders](install-packages.md#folders)). A token there could be fetched by anyone who reaches the server.
The text is the file `skill/skill.md` next to the program, found the same way as the [other folders](install-packages.md#folders), so this works with no source folder and no network.
A running server also hands the same text out, without a token, at `GET /skill.md`. That is how a machine that can only reach the server over the network gets it:
```bash
mkdir -p ~/.claude/skills/kdb
curl -s http://your-server:9900/skill.md -o ~/.claude/skills/kdb/SKILL.md
```
From Docker, ask the program inside the container:
```bash
docker exec kdb kdb skill > ~/.claude/skills/kdb/SKILL.md
```
Claude Code reads the folder when it starts, so open a new terminal afterwards.
## Building the packages yourself
```bash
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --target kdb
node tools/help/build_help.mjs --kdb build/bin/kdb.exe # the help pages -> docs/help/site (runs every example)
cpack --config build/CPackConfig.cmake # -> dist/kdb---
```
The Linux package is built inside the Docker image, which also links it static:
```bash
docker build --target package -o dist -f docker/Dockerfile .
```
A package holds the program, its `web`, `help` and `skill` folders, the installer and `kdb.conf.example`. No source, no tests, no build folder. The help build is its own step: without it the package has no `help` folder, and `/help` says how to add one.
---
# Quick start
> Get kdb, make a database, create a table, add rows, query them and open the web console.
- Category: Getting started
- Keywords: install, build, first steps, getting started, hello world, kdb version, kdb sql, kdb shell, kdb serve, console, sample data, tutorial
- See also: [Install packages and the Claude skill](install-packages.md), [SQL basics](sql-basics.md), [Sample data](sample-data.md), [Databases and the data folder](databases.md), [CREATE TABLE](create-table.md), [INSERT](insert.md), [Web console](web-console.md), [kdb serve](cli-serve.md), [What's new](whats-new.md)
KDB is one program: `kdb` (`kdb.exe` on Windows), with three folders of plain files next to it: `web/` (the web console), `help/` (these pages) and `skill/` (the Claude skill). It keeps each **database** in a folder. You talk to it with **SQL** (the language of databases). This page takes about 10 minutes.
KDB is a general-purpose database: market and time-series data, sensor readings, logs and metrics, accounting, users and logins, app backends. A small example: a table of daily stock prices, three rows, and a query.
**Example: Your first table, rows and query**
```sql
CREATE TABLE ochl_day (
time TIMESTAMP, symbol SYMBOL,
open DOUBLE, high DOUBLE, low DOUBLE, close DOUBLE, volume DOUBLE
) TIMESERIES(time) PARTITION BY YEAR DEDUP KEY (symbol, time);
INSERT INTO ochl_day VALUES
('2026-09-09', 'CITY', 512, 512.4, 504, 504.6, 182390),
('2026-09-10', 'CITY', 504.6, 507, 500.7, 504.9, 183040),
('2026-09-10', 'COAST', 380.5, 382, 375.2, 376.1, 186200);
SELECT time, symbol, close FROM ochl_day WHERE symbol = 'CITY';
```
Result:
`CREATE TABLE ochl_day (`
```text
table 'ochl_day' created
```
`INSERT INTO ochl_day VALUES ('2026-09-09', 'CITY', 512, 512.4, 504, 504.6, 182390), ('2026-09-10', '`
```text
3 row(s) inserted
```
`SELECT time, symbol, close FROM ochl_day WHERE symbol = 'CITY'`
| time (TIMESTAMP) | symbol (SYMBOL) | close (DOUBLE) |
|---|---|---|
| 2026-09-09T00:00:00.000Z | CITY | 504.6 |
| 2026-09-10T00:00:00.000Z | CITY | 504.9 |
(2 rows)
## Get kdb
There are three ways. Pick one.
| Way | You run | Then |
|---|---|---|
| An install package | unpack `kdb--windows-x64.zip` (or the Linux `.tar.gz`) and run `install.ps1` (or `install.sh`) | `kdb` is on your PATH, with its `web`, `help` and `skill` folders |
| Docker | `docker run` with the image `kdb` (below) | the server runs on port 9900, the PostgreSQL port on 8832 |
| Build from the source | `cmake` (below) | the program is in `build\bin\kdb.exe` |
[Install packages](install-packages.md) explains the packages and the Docker image in full.
### Docker
Syntax:
```sql
docker run -d --name kdb -p 9900:9900 -p 8832:8832 -e KDB_TOKEN= -v :/data kdb
```
The image holds the program and its three folders. Make it once from the source folder with `docker build -t kdb -f docker/Dockerfile .`. The data stays in the volume, so a new container keeps it.
- **Notes:** the server does not start without `KDB_TOKEN`. Paste the token into the console's token box, or send it as `Authorization: Bearer `. Add `--restart unless-stopped --stop-timeout 60` so it comes back after a restart and stops cleanly. See [Docker](install-packages.md#docker).
```bash
docker run -d --name kdb --restart unless-stopped --stop-timeout 60 \
-p 9900:9900 -p 8832:8832 -e KDB_TOKEN=my-secret -v kdb-data:/data kdb
curl http://127.0.0.1:9900/health
```
Then open and go on at [Open the web console](#console): the console and the HTTP API do everything the `kdb sql` steps below do.
### Build from the source
Syntax:
```sql
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build --target kdb
```
Build the program from the KDB source folder. You need CMake, Ninja and a C++17 compiler (on Windows: MSYS2 UCRT64). KDB needs no other libraries.
- **Returns:** the program `build\bin\kdb.exe` (Linux and macOS: `build/bin/kdb`).
- **Notes:** a `kdb` run from `build\bin` finds the console, the help and the skill in the source folder by itself (`web/console`, `docs/help/site`, `.claude/skills/kdb`). To copy it to another PC, copy `kdb.exe` together with the folders `web`, `help` and `skill`, or make a package. Without `web/` the HTTP API still works, but `http://127.0.0.1:9900/` says the console is not installed. See [where kdb finds them](install-packages.md#folders). [Chapter 1](../tutorial/01-install.md) explains every step.
```powershell
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build --target kdb
$env:Path = "$PWD\build\bin;" + $env:Path
```
Check that it works:
**Command line: Show the version**
```bash
kdb version
```
Output:
```text
kdb 0.2.0-dev (crc32c hardware: yes)
tls: OpenSSL 3.6.3 9 Jun 2026
Stored-format features this build reads (51):
views 0.2.0-dev (2026-09-10) CREATE VIEW: the "views" file
partition_format_2 0.2.0-dev (2026-09-11) table partition files in format 2 (one raw block per column)
block_stats 0.2.0-dev (2026-09-11) column stats section in partition files (count, min, max, sum per column)
runs 0.2.0-dev (2026-09-11) log record 'C': rows of a bulk upsert kept as columns
auto_meta 0.2.0-dev (2026-09-11) AUTO id counters in the manifest (auto.)
json 0.2.0-dev (2026-09-11) JSON columns (type code 12)
bytes 0.2.0-dev (2026-09-11) BYTES columns (type code 13)
check 0.2.0-dev (2026-09-11) CHECK constraints (catalog format 2, part 'C')
indexes 0.2.0-dev (2026-09-11) CREATE INDEX, UNIQUE and FULLTEXT indexes (catalog part 'I')
routines 0.2.0-dev (2026-09-11) functions, procedures and triggers: the "routines" file
users 0.2.0-dev (2026-09-11) server users and roles (_system.users)
audit 0.2.0-dev (2026-09-12) SET AUDIT ON: catalog part 'A', log record 'P', the _audit_log table
table_grants 0.2.0-dev (2026-09-12) GRANT ... ON TABLE (_system.users.table_grants)
totp 0.2.0-dev (2026-09-12) two-factor logins (_system.users.totp)
trash 0.2.0-dev (2026-09-12) dropped tables kept as _dropped__ for UNDROP TABLE
database_settings 0.2.0-dev (2026-09-12) ALTER DATABASE ... SET READ ONLY (_system.database_settings)
dialects 0.2.0-dev (2026-09-12) ALTER DATABASE ... SET dialect (_system.database_dialects)
pg_catalog_parts 0.2.0-dev (2026-09-12) PostgreSQL column types, multi-column keys, foreign keys, expression and partial indexes (catalog parts 'T', 'K', 'F', 'X')
sequences 0.2.0-dev (2026-09-12) CREATE SEQUENCE: the "sequences" file and its log records
batched_log_sync 0.2.0-dev (2026-09-11) log segments in the batched format (header flag 1, 16-byte commit records): group commit and --sync-commits batch
dedup_change 0.2.0-dev (2026-09-12) ALTER TABLE ... DEDUP ENABLE / DISABLE: the DEDUP KEY of a table that already holds rows
plpgsql_triggers 0.2.0-dev (2026-09-12) PostgreSQL plpgsql trigger functions and FOR EACH ROW triggers in the "routines" file
cached_views 0.2.0-dev (2026-09-13) CREATE CACHED VIEW: which views are cached, and REFRESH ON COMMIT, in the "views" file
deferred_triggers 0.2.0-dev (2026-09-13) CREATE TRIGGER ... DEFERRED: job records in the log, and the "trigger_queue" and "q/" files of queued jobs
materialized_views 0.2.0-dev (2026-09-13) CREATE MATERIALIZED VIEW: catalog part 'M', REFRESH MATERIALIZED VIEW log records, mv_dirty. in the manifest
table_ttl 0.2.0-dev (2026-09-13) TTL n DAYS on a table: catalog part 'L'
read_only_procedures 0.2.0-dev (2026-09-24) CREATE PROCEDURE ... READ ONLY in the "routines" file
dedup_insert 0.2.0-dev (2026-09-24) WITH (dedup_insert = 'upsert') on a table: catalog part 'U'
decimal128 0.2.0-dev (2026-09-25) NUMERIC(p, s) columns with more than 18 digits: 128-bit DECIMAL (type code 14) in the catalog, partition files and log
dedup_generated_key 0.2.0-dev (2026-09-25) a stored generated column in a DEDUP KEY, like the minute of the time (an older build refuses such a catalog)
symbol_partitions 0.2.0-dev (2026-09-25) PARTITION BY , SYMBOL(col): catalog part 'P' (one partition file per time bucket and symbol, t///s-...)
round_time_zone 0.2.0-dev (2026-09-25) TIMESTAMP ROUND ... TIME ZONE 'zone' columns: catalog part 'R'
enum_types 0.2.0-dev (2026-09-25) CREATE TYPE ... AS ENUM: the ENUM types after the tables of the catalog (catalog format 3)
row_ttl 0.2.0-dev (2026-09-25) CREATE TABLE ... WITH (ttl_column = c): rows expire one by one, catalog part 'E'
column_compression 0.2.0-dev (2026-09-25) ALTER TABLE ... SET (compression = on): catalog part 'Z' and packed partition files (header flag 2)
calendars 0.2.0-dev (2026-09-25) CREATE CALENDAR: trading calendars (eras, holidays, extra trading days) after the ENUM types of the catalog (catalog format 4)
materialized_view_sample_by 0.2.0-dev (2026-09-26) CREATE MATERIALIZED VIEW ... AS (SELECT ... SAMPLE BY ...): a SAMPLE BY query in catalog part 'M', refreshed bucket by bucket
comments 0.2.0-dev (2026-09-26) COMMENT ON TABLE / COLUMN: the comments of a table and its columns, catalog part 'N'
cache_tables 0.2.0-dev (2026-10-02) WITH (max_bytes, evict, durability): cache tables with a memory cap and eviction, catalog part 'Y'
resident_tables 0.2.0-dev (2026-10-02) WITH / SET (resident = on): a table never unloaded to make room, catalog part 'H'
partition_tails 0.2.0-dev (2026-10-02) tail files: rows appended to a partition saved in extra files t//.tail.b (tail_files)
uuid 0.2.0-dev (2026-10-02) UUID columns: 16 bytes per value (type code 15) in the catalog, partition files and log
search_analyzer 0.2.0-dev (2026-10-02) CREATE FULLTEXT INDEX ... WITH (language = 'english'): the word analyzer of a full-text index, catalog part 'S'
column_grants 0.2.0-dev (2026-10-02) GRANT / REVOKE on single columns (_system.users.column_grants)
row_policies 0.2.0-dev (2026-10-02) row-level security: CREATE POLICY and ALTER TABLE ... ENABLE ROW LEVEL SECURITY (_system.row_policies)
partition_deltas 0.2.0-dev (2026-10-02) delta files: a few rows of a partition replaced or deleted, saved in t//.delta.b
fulltext_segments 0.2.0-dev (2026-10-02) full-text and JSON index segments in fts/*.kfs (the words of each partition's rows; never recorded: an older build ignores them)
compressed_log 0.2.0-dev (2026-10-02) packed log records (type 3): the data records of a big commit written compressed (KDB_WAL_COMPRESS)
endpoints 0.2.0-dev (2026-10-03) CREATE ENDPOINT: saved queries served at GET /v/, kind 'E' in the routines file
rules 0.2.0-dev (2026-10-03) CREATE RULE: named checks, session hours and settings of a table (catalog part 'Q')
sync_commit_tables 0.2.0-dev (2026-10-03) WITH (synchronous_commit = on | off) of a table: catalog part 'G'
A database that uses a feature not in this list needs a newer KDB (kdb check exits with 3).
```
## Your first database
Syntax:
```sql
kdb sql database "SQL"
```
`kdb sql` opens a database, runs the SQL, prints the result and closes the database. When the database does not exist yet, KDB makes a new empty one.
| Argument | Type | Meaning |
|---|---|---|
| `database` | name or folder | a database name like `shop`, or a folder path like `D:\kdb\shop` |
| `"SQL"` | text | one or more statements, separated by `;` |
- **Returns:** the result of each statement. Exit code `0` means OK, `1` means an error.
- **Notes:** a name without `\`, `/`, `:` or `.` means a database in the data folder, if one with that name is there; else a folder with that name in the current folder. See [databases](databases.md).
**Command line: Make a table in a new database, add a row, read it**
```bash
kdb sql shop "CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL)"
kdb sql shop "INSERT INTO brokers VALUES (58, 'Northgate Securities'), (45, 'Imperial Securities')"
kdb sql shop "SELECT id, name FROM brokers ORDER BY id"
```
Output:
`> kdb sql shop "CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL)"`
```text
table 'brokers' created
```
`> kdb sql shop "INSERT INTO brokers VALUES (58, 'Northgate Securities'), (45, 'Imperial Securities')"`
```text
2 row(s) inserted
```
`> kdb sql shop "SELECT id, name FROM brokers ORDER BY id"`
```text
id | name
---+---------------------
45 | Imperial Securities
58 | Northgate Securities
(2 rows, 0.3 ms)
```
A mistake gives a clear message and exit code 1:
**Command line: A typing mistake in a table name** (shows an error)
```bash
kdb sql shop "CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL)"
kdb sql shop "SELECT * FROM brokerz"
```
Output:
`> kdb sql shop "CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL)"`
```text
table 'brokers' created
```
`> kdb sql shop "SELECT * FROM brokerz"`
```text
error: line 1, column 15: table or view 'brokerz' does not exist
```
exit code 1
## CREATE TABLE, INSERT and SELECT
Syntax:
```sql
CREATE TABLE name (column TYPE [rules], ...) [TIMESERIES(time_column) ...]
INSERT INTO name VALUES (value, ...), (value, ...)
SELECT columns FROM name [WHERE condition]
```
The three statements you use most. `CREATE TABLE` makes a table. `INSERT` adds rows. `SELECT` reads rows.
- There are two kinds of tables: **relational** (lists of things, like users) and **time-series** (rows with a time, like prices). See [CREATE TABLE](create-table.md).
- Text and times go in single quotes: `'CITY'`, `'2026-09-10'`.
- KDB shows times in UTC (the `Z` at the end). Write a local time with its zone offset, for example `+05:45` (Nepal) or `-04:00` (New York).
**Example: App users: a relational table**
```sql
CREATE TABLE users (
id LONG PRIMARY KEY AUTO,
email STRING NOT NULL UNIQUE,
name STRING
);
INSERT INTO users (email, name) VALUES ('ram@example.com', 'Ram'), ('sita@example.com', 'Sita');
SELECT id, email, name FROM users;
```
Result:
`CREATE TABLE users (`
```text
table 'users' created
```
`INSERT INTO users (email, name) VALUES ('ram@example.com', 'Ram'), ('sita@example.com', 'Sita')`
```text
2 row(s) inserted
```
`SELECT id, email, name FROM users`
| id (LONG) | email (STRING) | name (STRING) |
|---|---|---|
| 1 | ram@example.com | Ram |
| 2 | sita@example.com | Sita |
(2 rows)
**Example: A local time goes in with its offset (+05:45) and comes out in UTC**
```sql
CREATE TABLE live_prices (time TIMESTAMP, symbol SYMBOL, ltp DOUBLE) TIMESERIES(time);
INSERT INTO live_prices VALUES ('2026-09-10T11:00:00+05:45', 'CITY', 513.0);
SELECT time, symbol, ltp FROM live_prices;
```
Result:
`CREATE TABLE live_prices (`
```text
table 'live_prices' created
```
`INSERT INTO live_prices VALUES ('2026-09-10T11:00:00+05:45', 'CITY', 513.0)`
```text
1 row(s) inserted
```
`SELECT time, symbol, ltp FROM live_prices`
| time (TIMESTAMP) | symbol (SYMBOL) | ltp (DOUBLE) |
|---|---|---|
| 2026-09-10T05:15:00.000Z | CITY | 513 |
(1 row)
## kdb shell
Syntax:
```sql
kdb shell [database]
```
A prompt where you type SQL. End each statement with `;`. A statement can use many lines. Commands that start with a dot (`.tables`, `.help`, `.exit`) need no `;`.
- **Notes:** without a database name, the shell opens the database `default` in the data folder. There you can also use `CREATE DATABASE` and `USE`.
**Command line: Type SQL in the shell**
```bash
kdb shell shop
> CREATE TABLE posts (id LONG PRIMARY KEY AUTO, author STRING, body STRING);
> INSERT INTO posts (author, body) VALUES ('ram', 'CITY dividend looks good.');
> .tables
> SELECT id, author, body FROM posts;
> .exit
```
Output:
```text
KDB 0.2.0-dev shell on shop
End statements with ';'. Type .help for commands, .exit to quit.
kdb> table 'posts' created
kdb> 1 row(s) inserted
kdb> name | kind | partition | columns | rows
------+------------+-----------+---------+-----
posts | relational | NONE | 3 | 1
(1 row, 0.0 ms)
kdb> id | author | body
---+--------+--------------------------
1 | ram | CITY dividend looks good.
(1 row, 0.2 ms)
kdb>
```
## Load the sample data
Syntax:
```sql
Get-Content file.sql -Raw | kdb sql database -
```
The tutorial has a small made-up stock market dataset: securities, brokers, daily and minute candles, a trade log and live prices. It is just one example of what KDB stores. The `-` means "read the SQL from the pipe". Run the two files once, in this order.
- **Notes:** the help examples with `fixture=sample` use this data. [Sample data](sample-data.md) shows every table.
```powershell
# from the KDB source folder
$examples = "docs\tutorial\examples"
Get-Content "$examples\setup\schema.sql" -Raw | kdb sql market -
Get-Content "$examples\setup\sample-data.sql" -Raw | kdb sql market -
```
**Command line: The tables of the sample data** (sample data: sample)
```bash
kdb sql market "SHOW TABLES"
```
Output:
```text
name | kind | partition | columns | rows
-------------+------------+-----------+---------+-----
brokers | relational | NONE | 2 | 5
live_data | timeseries | DAY | 12 | 12
ochl_day | timeseries | YEAR | 7 | 57
ochl_minutes | timeseries | MONTH | 7 | 956
securities | relational | NONE | 3 | 5
trade_log | timeseries | DAY | 8 | 32
(6 rows, 0.0 ms)
```
**Example: The last 3 daily closes of CITY** (sample data: sample)
```sql
SELECT time, symbol, close FROM ochl_day WHERE symbol = 'CITY' ORDER BY time DESC LIMIT 3;
```
Result:
| time (TIMESTAMP) | symbol (SYMBOL) | close (DOUBLE) |
|---|---|---|
| 2026-09-10T00:00:00.000Z | CITY | 504.9 |
| 2026-09-09T00:00:00.000Z | CITY | 504.6 |
| 2026-09-08T00:00:00.000Z | CITY | 497.5 |
(3 rows)
## Open the web console (kdb serve)
Syntax:
```sql
kdb serve [database] [--port 9900]
```
Starts the server. Open in a browser. Type SQL, press **Ctrl+Enter**, and see a grid or a chart. Press **Ctrl+C** to stop the server.
- **Notes:** while the server runs, other `kdb` commands cannot open the same database. Use the console or the HTTP API then. Without a database name, the server serves every database in the data folder. The same server answers PostgreSQL tools on port 8832 and serves these help pages at . See [kdb serve](cli-serve.md) and [web console](web-console.md).
- **In Docker** the server is already running: open and paste your `KDB_TOKEN` into the token box.
**Command line: Start the server on the sample data** (sample data: sample)
```bash
kdb serve market
```
Output:
```text
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 0 ms, 1 files read 0 ms (8 table files left for their first use), log check 1 ms, replay 1 ms (0 transactions), clean-up 0 ms
Press Ctrl+C to stop.
```
## Next steps
Syntax:
```sql
SQL basics -> tables -> writing data -> queries
```
Read the topics in this order:
1. [SQL basics](sql-basics.md): names, text, numbers, NULL and comments.
2. [Databases](databases.md) and [CREATE TABLE](create-table.md).
3. [INSERT](insert.md), [UPDATE and DELETE](update-delete.md).
4. [SELECT](select.md) and [SAMPLE BY](sample-by.md) for candles.
Or follow the [tutorial](../tutorial/02-first-database.md) chapter by chapter. [What's new](whats-new.md) lists what was added lately.
**Example: A taste of time-series SQL: hourly candles of CITY** (sample data: sample)
```sql
SELECT time, first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close, sum(volume) AS volume
FROM ochl_minutes
WHERE symbol = 'CITY' AND time IN '2026-09-10'
SAMPLE BY 1h;
```
Result:
| time (TIMESTAMP) | open (DOUBLE) | high (DOUBLE) | low (DOUBLE) | close (DOUBLE) | volume (DOUBLE) |
|---|---|---|---|---|---|
| 2026-09-10T05:00:00.000Z | 504.6 | 507 | 503.8 | 506.4 | 36170 |
| 2026-09-10T06:00:00.000Z | 506.4 | 506.6 | 500.7 | 501.2 | 41280 |
| 2026-09-10T07:00:00.000Z | 501.2 | 504.1 | 500.7 | 502.6 | 46160 |
| 2026-09-10T08:00:00.000Z | 502.6 | 507 | 501.9 | 505 | 47560 |
| 2026-09-10T09:00:00.000Z | 505 | 506.1 | 504.3 | 504.9 | 11870 |
(5 rows)
---
# SQL basics
> How KDB reads SQL text: statements, names, text, numbers, TRUE/FALSE/NULL, comments, errors and reserved words.
- Category: Getting started
- Keywords: syntax, statement, semicolon, ;, case, upper case, lower case, identifier, name, quoted name, double quotes, string, text, single quote, escape quote, number, integer, float, scientific notation, boolean, constant, comment, --, /\*, error message, line and column, syntax error, reserved word, keyword, three-valued logic, IS NULL, IS NOT NULL, unknown, alias
- See also: [Quick start](quick-start.md), [Operators](operators.md), [Conditional and NULL functions](functions-conditional.md), [Column types](column-types.md), [SELECT](select.md), [WHERE](where.md)
**SQL** is the language you use to talk to KDB. A **statement** is one command, like `SELECT` or `INSERT`. This page shows the small rules that every statement follows.
**Example: Names, text, numbers, NULL and a comment in one query**
```sql
-- the price of one CITY share, and a note
SELECT 'CITY' AS symbol,
512.45 AS "Last Price", /* a name with a space needs double quotes */
100 AS quantity,
512.45 * 100 AS amount,
'City''s bank' AS note,
NULL AS broker;
```
Result:
| symbol (STRING) | Last Price (DOUBLE) | quantity (LONG) | amount (DOUBLE) | note (STRING) | broker (STRING) |
|---|---|---|---|---|---|
| CITY | 512.45 | 100 | 51245 | City's bank | NULL |
(1 row)
## Statements and ;
Syntax:
```sql
statement ; statement ; ...
```
A text can hold many statements. Put `;` between them. The `;` after the last statement is optional. Extra `;` are ignored.
- When you send a text (with `kdb sql`, a file, or HTTP `/exec`), KDB first checks the **grammar** of the whole text. If one statement has a syntax error, **no** statement runs. (`kdb shell` runs what you typed each time a line ends with `;`.)
- Then the statements run one by one. Each one is saved on its own.
- At the first error while running, KDB **stops**. The statements before it stay saved. The statements after it do not run.
- One statement is all or nothing: an `INSERT` of 5000 rows with one bad row saves no row.
- **Notes:** to make many statements succeed or fail together, use [BEGIN ... COMMIT](transactions.md).
**Example: Three statements, one result each**
```sql
CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL);
INSERT INTO brokers VALUES (58, 'Northgate Securities');;
SELECT id, name FROM brokers
```
Result:
`CREATE TABLE brokers (`
```text
table 'brokers' created
```
`INSERT INTO brokers VALUES (58, 'Northgate Securities')`
```text
1 row(s) inserted
```
`SELECT id, name FROM brokers`
| id (INT) | name (STRING) |
|---|---|
| 58 | Northgate Securities |
(1 row)
**Example: A script stops at the first error; row 21 stays saved** (shows an error)
Setup:
```sql
CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL);
```
```sql
INSERT INTO brokers VALUES (21, 'Test Broker A');
INSERT INTO brokers VALUES (21, 'Test Broker B');
INSERT INTO brokers VALUES (22, 'Test Broker C');
```
Result:
```text
1 row(s) inserted
```
Error: `a row with id='21' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it`
**Example: A syntax error in the second statement: the first does not run either** (shows an error)
```sql
CREATE TABLE notes (id INT, body STRING);
SELEC * FROM notes;
```
Result:
Error: `line 2, column 1: expected a statement (CREATE, DROP, RENAME, ALTER, SHOW, INSERT, UPDATE, DELETE, BACKUP, CHECK, SELECT, EXPLAIN, CALL, BEGIN, COMMIT, ROLLBACK, SAVEPOINT, DECLARE, FETCH or CLOSE) but found 'SELEC'`
## Upper and lower case
Syntax:
```sql
SELECT = select = Select
ochl_day = OCHL_DAY = Ochl_Day
'CITY' <> 'city'
```
Keywords, table names, column names and function names do not care about case. **Text values do**: `'CITY'` is not `'city'`.
- A table or column keeps the spelling you used in `CREATE TABLE`. `SHOW TABLES` shows that spelling.
- A column name in the result keeps the spelling of the alias you wrote.
- **Notes:** in PostgreSQL, a name in double quotes is case sensitive. In KDB it is not: `"Total"` and `"total"` are the same name.
**Example: Any case works for names; text compares exactly**
```sql
CREATE TABLE Brokers (Id INT PRIMARY KEY, Name STRING);
INSERT INTO BROKERS (ID, NAME) VALUES (58, 'Northgate Securities');
select id, name, name = 'northgate securities' AS same_text, upper(name) = 'NORTHGATE SECURITIES' AS same_upper from brokers;
```
Result:
`CREATE TABLE Brokers (`
```text
table 'Brokers' created
```
`INSERT INTO BROKERS (ID, NAME) VALUES (58, 'Northgate Securities')`
```text
1 row(s) inserted
```
`SELECT id, name, name = 'northgate securities' AS same_text, upper(name) = 'NORTHGATE SECURITIES' AS`
| Id (INT) | Name (STRING) | same_text (BOOL) | same_upper (BOOL) |
|---|---|---|---|
| 58 | Northgate Securities | false | true |
(1 row)
## Names and "quoted names"
Syntax:
```sql
name
"any name"
"a name with a "" inside"
```
A **name** (also called identifier) names a table, column, alias, view or index. A plain name has letters, digits and `_`, and does not start with a digit. Letters of any language (Nepali, Chinese, ...) also work.
Write a name in **double quotes** when it:
- is a [reserved word](#reserved-words), like `"order"` or `"select"`;
- has a space or another special character, like `"Total Amount"`;
- starts with a digit, like `"1st_level"`.
| Part | Rule |
|---|---|
| Plain name | letters (any language), digits, `_`; not starting with a digit |
| Quoted name | any characters; a `"` inside is written twice: `""` |
| Empty name `""` | not allowed |
- **Notes:** `time`, `date`, `timestamp`, `symbol`, `partition`, `open`, `close`, `volume`, `value` and `key` are **not** reserved. Use them freely as column names.
**Example: A forum table with a column called order**
```sql
CREATE TABLE posts (id LONG PRIMARY KEY, "order" INT, "Posted By" STRING, body STRING);
INSERT INTO posts VALUES (1, 1, 'ram', 'First post');
SELECT id, "order", "Posted By" FROM posts ORDER BY "order";
```
Result:
`CREATE TABLE posts (`
```text
table 'posts' created
```
`INSERT INTO posts VALUES (1, 1, 'ram', 'First post')`
```text
1 row(s) inserted
```
`SELECT id, "order", "Posted By" FROM posts ORDER BY "order"`
| id (LONG) | order (INT) | Posted By (STRING) |
|---|---|---|
| 1 | 1 | ram |
(1 row)
**Example: Names in any language work without quotes (here Nepali)**
```sql
CREATE TABLE कम्पनी (चिन्ह STRING PRIMARY KEY, नाम STRING);
INSERT INTO कम्पनी VALUES ('CITY', 'नमस्ते');
SELECT चिन्ह, नाम FROM कम्पनी;
```
Result:
`CREATE TABLE कम्पनी (`
```text
table 'कम्पनी' created
```
`INSERT INTO कम्पनी VALUES ('CITY', 'नमस्ते')`
```text
1 row(s) inserted
```
`SELECT चिन्ह, नाम FROM कम्पनी`
| चिन्ह (STRING) | नाम (STRING) |
|---|---|
| CITY | नमस्ते |
(1 row)
**Example: A reserved word without quotes** (shows an error)
```sql
CREATE TABLE orders (id INT, order STRING);
```
Result:
Error: `line 1, column 30: expected a column name but found the reserved word 'order' (write it in double quotes, "order", to use it as a name)`
## Text: 'single quotes'
Syntax:
```sql
'text'
'City''s bank'
''
```
Text (also called a string) goes in **single quotes**. A `'` inside the text is written twice: `''`. Text may have many lines and any language (UTF-8).
- There are no backslash escapes: `'a\nb'` is the four characters `a`, `\`, `n`, `b`.
- `''` is an empty text. It is **not** NULL.
- Double quotes `"..."` are for names, never for text. `WHERE symbol = "CITY"` looks for a **column** called CITY.
- **Notes:** bytes are written `X'0A0B'`; see [BYTES](column-types.md#bytes). Times are text too: `'2026-09-10T11:00:00+05:45'`; see [TIMESTAMP](column-types.md#timestamp).
**Example: Quotes inside text, empty text, text in another language**
```sql
SELECT 'City''s bank' AS name,
'' AS empty, '' IS NULL AS empty_is_null,
'नेपाल' AS nepali,
'a\nb' AS no_escapes;
```
Result:
| name (STRING) | empty (STRING) | empty_is_null (BOOL) | nepali (STRING) | no_escapes (STRING) |
|---|---|---|---|---|
| City's bank | | false | नेपाल | a\\nb |
(1 row)
**Example: Double quotes mean a column name, not text** (shows an error)
```sql
CREATE TABLE securities (symbol SYMBOL PRIMARY KEY, name STRING);
SELECT name FROM securities WHERE symbol = "CITY";
```
Result:
```text
table 'securities' created
```
Error: `line 2, column 44: unknown column 'CITY' (table 'securities' has: symbol name)`
**Example: Text without its closing quote** (shows an error)
```sql
SELECT 'Northgate;
```
Result:
Error: `line 1, column 8: expected a closing ' to end the string that starts here but found the end of the input`
## Numbers
Syntax:
```sql
42 -7 512.45 .5 1.5e3 2E-4
```
A number without a point or `e` is a whole number (type `LONG`). A number with a point or `e` is a `DOUBLE`.
| Written | Type | Range |
|---|---|---|
| `42`, `-7` | `LONG` | -9223372036854775808 to 9223372036854775807 |
| `512.45`, `.5`, `1.5e3` | `DOUBLE` | about ±1.8e308, 15 digits |
- There is no `,` or `_` inside a number: write `1000000`, not `1,000,000`.
- A number put into a column is converted to the column type; see [column types](column-types.md).
- `/` always gives a `DOUBLE`: `10 / 4` is `2.5`. See [arithmetic](functions-math.md#operators).
- **Notes:** for exact money, the column type matters, not the literal: use `DECIMAL(2)` columns. For amounts with more than about 15 digits, write the value as text: `'123456789012345.67'`.
**Example: Kinds of numbers**
```sql
SELECT 42 AS whole, -7 AS negative, 512.45 AS price, .5 AS half, 1.5e3 AS thousand_five, 2E-4 AS small, 10 / 4 AS division;
```
Result:
| whole (LONG) | negative (LONG) | price (DOUBLE) | half (DOUBLE) | thousand_five (DOUBLE) | small (DOUBLE) | division (DOUBLE) |
|---|---|---|---|---|---|---|
| 42 | -7 | 512.45 | 0.5 | 1500 | 0.0002 | 2.5 |
(1 row)
**Example: A whole number that is too big** (shows an error)
```sql
SELECT 9223372036854775808;
```
Result:
Error: `line 1, column 8: expected an integer between -9223372036854775808 and 9223372036854775807 but found '9223372036854775808'`
## TRUE, FALSE and NULL
Syntax:
```sql
TRUE FALSE NULL
```
`TRUE` and `FALSE` are the two `BOOL` values. `NULL` means "no value" or "unknown". Every column type can hold `NULL`, unless the column is `NOT NULL`.
- `NULL` is not `0`, not `''` and not `FALSE`.
- Use `IS NULL` and `IS NOT NULL` to test for `NULL`. See [NULL logic](#null-logic).
- **Notes:** a `BOOL` is not a number: `TRUE = 1` is an error. A `BOOL` column accepts `1`/`0` and `'true'`/`'false'` when a row is written.
**Example: The three constants**
```sql
SELECT TRUE AS yes, FALSE AS no, NULL AS nothing, NULL IS NULL AS is_null, NOT FALSE AS not_false;
```
Result:
| yes (BOOL) | no (BOOL) | nothing (STRING) | is_null (BOOL) | not_false (BOOL) |
|---|---|---|---|---|
| true | false | NULL | true | true |
(1 row)
**Example: A user who has not verified the email yet**
```sql
CREATE TABLE users (id LONG PRIMARY KEY AUTO, email STRING NOT NULL, verified BOOL NOT NULL DEFAULT FALSE, phone STRING);
INSERT INTO users (email) VALUES ('ram@example.com');
INSERT INTO users (email, verified, phone) VALUES ('sita@example.com', TRUE, '9800000000');
SELECT id, email, verified, phone, phone IS NULL AS no_phone FROM users;
```
Result:
`CREATE TABLE users (`
```text
table 'users' created
```
`INSERT INTO users (email) VALUES ('ram@example.com')`
```text
1 row(s) inserted
```
`INSERT INTO users (email, verified, phone) VALUES ('sita@example.com', TRUE, '9800000000')`
```text
1 row(s) inserted
```
`SELECT id, email, verified, phone, phone IS NULL AS no_phone FROM users`
| id (LONG) | email (STRING) | verified (BOOL) | phone (STRING) | no_phone (BOOL) |
|---|---|---|---|---|
| 1 | ram@example.com | false | NULL | true |
| 2 | sita@example.com | true | 9800000000 | false |
(2 rows)
## Comments
Syntax:
```sql
-- to the end of the line
/* between these marks, also over many lines */
```
KDB skips comments. Use them to explain a script.
- `/* ... */` comments do not nest: the first `*/` ends the comment.
- A `/*` without `*/` is an error that shows where the comment starts.
**Example: Both kinds of comments in a report query**
Setup:
```sql
CREATE TABLE ledger (id LONG PRIMARY KEY AUTO, account INT, amount DECIMAL(2));
INSERT INTO ledger (account, amount) VALUES (1, 1000.00), (1, -250.50), (2, 250.50);
```
```sql
/* Balance per account.
Used by the daily report. */
SELECT account, -- the account number
sum(amount) AS balance
FROM ledger
GROUP BY account
ORDER BY account;
```
Result:
| account (INT) | balance (DECIMAL(2)) |
|---|---|
| 1 | 749.50 |
| 2 | 250.50 |
(2 rows)
**Example: A comment that is not closed** (shows an error)
```sql
SELECT 1 /* forgot to close
```
Result:
Error: `line 1, column 10: expected '*/' to close the comment that starts here but found the end of the input`
## Error messages
Syntax:
```sql
error: line L, column C: expected ... but found ...
error:
```
When KDB cannot run a statement, it prints `error:` and a message. Messages about the SQL text say **where**: `line 3, column 8` is the place in the text you sent (in a file: the line of the file). They also say what KDB expected there.
| Message starts with | Meaning |
|---|---|
| `expected ... but found ...` | the grammar is wrong at that place |
| `unknown column 'x' (table 't' has: ...)` | a typing mistake; the message lists the real columns |
| `table or view 'x' does not exist` | wrong table name, or the wrong database |
| `a row with ... already exists` | a duplicate key; see [INSERT](insert.md#duplicates) |
| `column 'x' (TYPE): ...` | a value does not fit the column; see [column types](column-types.md) |
- **Returns:** exit code `1` from `kdb sql`; HTTP status 400 from the HTTP API.
- **Notes:** a missing comma is not an error: `SELECT symbol close` means "column `symbol`, named `close`". [Chapter 12](../tutorial/12-troubleshooting.md) lists many messages and their fixes.
**Example: The message points to line 3, column 8** (shows an error)
Setup:
```sql
CREATE TABLE ochl_day (time TIMESTAMP, symbol SYMBOL, close DOUBLE) TIMESERIES(time);
```
```sql
SELECT time,
symbol,
clsoe
FROM ochl_day;
```
Result:
Error: `line 3, column 8: unknown column 'clsoe' (table 'ochl_day' has: time symbol close)`
**Example: What the grammar wanted** (shows an error)
Setup:
```sql
CREATE TABLE ochl_day (time TIMESTAMP, symbol SYMBOL, close DOUBLE) TIMESERIES(time);
```
```sql
SELECT symbol close FROM ochl_day WHERE;
```
Result:
Error: `line 1, column 40: expected an expression (a value, column name, function call or '(') but found ';'`
## Reserved words
Syntax:
```sql
ALTER AND AS BETWEEN CASE CREATE DEFAULT DELETE DISTINCT DROP ELSE END
FALSE FROM GROUP IN INSERT INTO IS JOIN LIKE LIMIT NOT NULL ON OR ORDER
PRIMARY REFERENCES SELECT SET TABLE THEN TO TRUE UNIQUE UPDATE VALUES WHEN WHERE
```
These 40 words have a fixed meaning. They cannot be a table, column or alias name, unless you write them in double quotes: `"order"`, `"from"`.
- All other words are normal names, also words like `CHECK`, `INDEX`, `BEGIN`, `KEY`, `USER`, `DATE`, `TIME`, `OPEN` and `CLOSE`: KDB knows them from their place in the statement.
- **Notes:** PostgreSQL reserves more words. A name that works in KDB may need quotes in PostgreSQL.
**Example: Columns called time, date, open, close, key and value need no quotes**
```sql
CREATE TABLE prices (time TIMESTAMP, date DATE, open DOUBLE, close DOUBLE, key STRING, value DOUBLE) TIMESERIES(time);
INSERT INTO prices VALUES ('2026-09-10', '2026-09-10', 504.6, 504.9, 'CITY', 1);
SELECT time, date, open, close, key, value FROM prices;
```
Result:
`CREATE TABLE prices (`
```text
table 'prices' created
```
`INSERT INTO prices VALUES ('2026-09-10', '2026-09-10', 504.6, 504.9, 'CITY', 1)`
```text
1 row(s) inserted
```
`SELECT time, date, open, close, key, value FROM prices`
| time (TIMESTAMP) | date (DATE) | open (DOUBLE) | close (DOUBLE) | key (STRING) | value (DOUBLE) |
|---|---|---|---|---|---|
| 2026-09-10T00:00:00.000Z | 2026-09-10 | 504.6 | 504.9 | CITY | 1 |
(1 row)
**Example: A reserved word as a column name** (shows an error)
```sql
CREATE TABLE transfers (id INT, from INT, to_account INT);
```
Result:
Error: `line 1, column 33: expected a column name but found the reserved word 'from' (write it in double quotes, "from", to use it as a name)`
## Clause words
Syntax:
```sql
ASOF CROSS FULL HAVING INNER LATEST LEFT LT OVER REFRESH RIGHT SAMPLE SPLICE UNION USING
```
These 15 words start a part of a query (a **clause**), like `LEFT JOIN` or `SAMPLE BY`. They **can** be table and column names. But they cannot be a short alias without `AS`: write `SELECT 1 AS left`, not `SELECT 1 left`.
| Word | Used in |
|---|---|
| `INNER`, `LEFT`, `RIGHT`, `FULL`, `CROSS`, `USING` | [joins](joins.md) |
| `ASOF`, `LT`, `SPLICE` | [time joins](joins.md#asof-join) |
| `HAVING` | [GROUP BY](group-by.md) |
| `LATEST` | [LATEST ON](latest-on.md) |
| `SAMPLE` | [SAMPLE BY](sample-by.md) |
| `UNION` | [UNION ALL](union-subqueries.md) |
| `OVER` | [window functions](functions-window.md) |
| `REFRESH` | `CREATE CACHED VIEW ... REFRESH ON COMMIT` ([views](views.md)) |
**Example: Clause words as column names, and as aliases with AS**
```sql
CREATE TABLE t (left INT, right INT, sample INT, latest INT);
INSERT INTO t VALUES (1, 2, 3, 4);
SELECT left, right, sample AS union, latest AS over FROM t;
```
Result:
`CREATE TABLE t (`
```text
table 't' created
```
`INSERT INTO t VALUES (1, 2, 3, 4)`
```text
1 row(s) inserted
```
`SELECT left, right, sample AS union, latest AS over FROM t`
| left (INT) | right (INT) | union (INT) | over (INT) |
|---|---|---|---|
| 1 | 2 | 3 | 4 |
(1 row)
**Example: RIGHT JOIN: every broker, also the ones without trades**
Setup:
```sql
CREATE TABLE brokers (id INT PRIMARY KEY, name STRING);
CREATE TABLE trades (id LONG PRIMARY KEY, buyer INT);
INSERT INTO brokers VALUES (1, 'Alpha'), (2, 'Beta');
INSERT INTO trades VALUES (10, 1);
```
```sql
SELECT brokers.name, trades.id FROM trades RIGHT JOIN brokers ON trades.buyer = brokers.id ORDER BY brokers.name;
```
Result:
| name (STRING) | id (LONG) |
|---|---|
| Alpha | 10 |
| Beta | NULL |
(2 rows)
**Example: A clause word as a short alias** (shows an error)
```sql
SELECT 1 left;
```
Result:
Error: `line 1, column 10: expected an operator, AS, ',', FROM, WHERE, GROUP BY, UNION ALL, ORDER BY, LIMIT, ';' or the end of the input but found 'left'`
## NULL logic
Syntax:
```sql
value IS NULL
value IS NOT NULL
NULL = anything -> NULL (unknown)
```
`NULL` means "unknown". So most things done with `NULL` give `NULL` again:
| Expression | Result | Why |
|---|---|---|
| `NULL + 1` | `NULL` | unknown plus 1 is unknown |
| `NULL = NULL` | `NULL` | two unknown values may differ |
| `TRUE AND NULL` | `NULL` | depends on the unknown value |
| `FALSE AND NULL` | `FALSE` | false whatever the unknown value is |
| `TRUE OR NULL` | `TRUE` | true whatever the unknown value is |
| `NOT NULL` | `NULL` | |
| `3 IN (1, NULL)` | `NULL` | 3 might be the unknown value |
| `NULL IS NULL` | `TRUE` | the right way to test |
- `WHERE` and `HAVING` keep a row only when the condition is `TRUE`. A `NULL` condition drops the row, like `FALSE`.
- So `WHERE phone = NULL` finds nothing. Write `WHERE phone IS NULL`.
- `WHERE v <> 5` also drops the rows where `v` is `NULL`.
- Aggregates skip `NULL`: `count(v)` counts only values, `sum(v)` adds only values. `count(*)` counts all rows.
- `ORDER BY v` puts `NULL` last (with `DESC`: first).
- **Notes:** `coalesce(v, 0)` gives `0` for `NULL`; see [conditional functions](functions-conditional.md).
**Example: NULL in expressions**
```sql
SELECT NULL + 1 AS plus, NULL = NULL AS equal, TRUE AND NULL AS and_true, FALSE AND NULL AS and_false,
TRUE OR NULL AS or_true, 3 IN (1, NULL) AS in_list, NULL IS NULL AS is_null;
```
Result:
| plus (STRING) | equal (STRING) | and_true (STRING) | and_false (BOOL) | or_true (BOOL) | in_list (STRING) | is_null (BOOL) |
|---|---|---|---|---|---|---|
| NULL | NULL | NULL | false | true | NULL | true |
(1 row)
**Example: Users without a phone: = NULL finds nothing, IS NULL works**
Setup:
```sql
CREATE TABLE users (id INT PRIMARY KEY, email STRING, phone STRING);
INSERT INTO users VALUES (1, 'ram@example.com', NULL), (2, 'sita@example.com', '9800000000'), (3, 'hari@example.com', NULL);
```
```sql
SELECT id FROM users WHERE phone = NULL;
SELECT id FROM users WHERE phone IS NULL;
SELECT id FROM users WHERE phone <> '9800000000';
SELECT count(*) AS users, count(phone) AS with_phone FROM users;
```
Result:
`SELECT id FROM users WHERE phone = NULL`
| id (INT) |
|---|
(0 rows)
`SELECT id FROM users WHERE phone IS NULL`
| id (INT) |
|---|
| 1 |
| 3 |
(2 rows)
`SELECT id FROM users WHERE phone != '9800000000'`
| id (INT) |
|---|
(0 rows)
`SELECT count(*) AS users, count(phone) AS with_phone FROM users`
| users (LONG) | with_phone (LONG) |
|---|---|
| 3 | 1 |
(1 row)
---
# SQL command groups: DDL, DML, DCL, TCL, DQL
> The five groups of SQL statements, every KDB statement in its group, and the role each group needs.
- Category: Getting started
- Keywords: DDL, DML, DCL, TCL, DQL, data definition language, data manipulation language, data control language, transaction control language, data query language, command groups, statement types, kinds of statements, sublanguage, which role do I need, TRUNCATE, SAVEPOINT
- See also: [SQL basics](sql-basics.md), [CREATE TABLE](create-table.md), [ALTER, RENAME and DROP TABLE](alter-table.md), [Views](views.md), [Indexes](indexes.md), [INSERT](insert.md), [UPDATE and DELETE](update-delete.md), [Users and roles](users-and-roles.md), [Transactions](transactions.md), [SELECT](select.md), [SHOW TABLES, COLUMNS, CREATE TABLE](show-tables.md)
SQL statements are sorted into five **groups** by what they do. The group tells you three things at once: what the statement changes, which [role](users-and-roles.md#roles) a user needs for it, and whether it can go inside [BEGIN ... COMMIT](transactions.md).
| Group | Name | What it does | Main statements | Role needed |
|---|---|---|---|---|
| **DDL** | Data Definition Language | makes, changes and removes **objects**: tables, views, indexes, functions, procedures, triggers | `CREATE`, `ALTER`, `DROP`, `RENAME` | `admin` |
| **DML** | Data Manipulation Language | changes the **rows** inside tables | `INSERT`, `UPDATE`, `DELETE`, `CALL` | `write` |
| **DCL** | Data Control Language | says **who** may do what | `CREATE USER`, `GRANT`, `REVOKE` | `admin` as the user's own `ROLE` |
| **TCL** | Transaction Control Language | groups changes so they are saved **together or not at all** | `BEGIN`, `COMMIT`, `ROLLBACK` | `read` |
| **DQL** | Data Query Language | **reads** rows and never changes anything | `SELECT`, `EXPLAIN`, `SHOW` | `read` |
- Roles are checked only by `kdb serve` (HTTP, web console and the PostgreSQL port). `kdb sql` and `kdb shell` open the files directly and may run everything.
- Some books put `SELECT` into DML. KDB's help keeps it apart as DQL, because a read needs only the `read` role.
- The web console lists what DDL made: open the **Objects** list on the left (see [web console](web-console.md#tables)).
## DDL: Data Definition Language
DDL statements build the **shape** of a database. They change objects, not rows (except `DROP`, which removes the rows with the table).
| Object | Make | Change | Remove | Help |
|---|---|---|---|---|
| table | `CREATE TABLE` | `ALTER TABLE`, `RENAME TABLE` | `DROP TABLE`, then `UNDROP TABLE` to bring it back | [create-table](create-table.md), [alter-table](alter-table.md), [trash](trash.md) |
| view | `CREATE VIEW`, `CREATE CACHED VIEW` | drop and create again | `DROP VIEW` | [views](views.md) |
| materialized view | `CREATE MATERIALIZED VIEW` | `ALTER TABLE ... SET TTL`; otherwise drop and create again | `DROP MATERIALIZED VIEW` | [views](views.md#materialized-view) |
| index | `CREATE INDEX`, `CREATE UNIQUE INDEX`, `CREATE FULLTEXT INDEX` | - | `DROP INDEX` | [indexes](indexes.md) |
| saved function | `CREATE FUNCTION` | `CREATE OR REPLACE FUNCTION` | `DROP FUNCTION` | [saved-functions](saved-functions.md) |
| stored procedure | `CREATE PROCEDURE` | `CREATE OR REPLACE PROCEDURE` | `DROP PROCEDURE` | [procedures](procedures.md) |
| trigger | `CREATE TRIGGER` | `ALTER TRIGGER ... ENABLE / DISABLE` | `DROP TRIGGER` | [triggers](triggers.md) |
| sequence | `CREATE SEQUENCE` | `ALTER SEQUENCE` | `DROP SEQUENCE` | [functions-postgres](functions-postgres.md) |
| ENUM type | `CREATE TYPE ... AS ENUM` | `ALTER TYPE ... ADD VALUE` | `DROP TYPE` | [column-types](column-types.md#enum) |
| trading calendar | `CREATE CALENDAR` | `ALTER CALENDAR ... ADD HOLIDAY` / `ADD ERA` / ... | `DROP CALENDAR` | [calendars](calendars.md) |
| database | `CREATE DATABASE` | `ALTER DATABASE` | `DROP DATABASE` | [databases](databases.md) |
- **Role:** `admin`. Databases need `admin` as the user's own `ROLE`.
- **Notes:**
- Each DDL statement is saved on its own, at once. Most of them can not go inside `BEGIN` ... `COMMIT` (see [what is not allowed inside a transaction](transactions.md#not-inside)).
- Look at what DDL made with `SHOW TABLES`, `SHOW COLUMNS FROM t`, `SHOW CREATE TABLE t`, `SHOW VIEWS`, `SHOW INDEXES`, `SHOW FUNCTIONS`, `SHOW PROCEDURES` and `SHOW TRIGGERS` (see [show-tables](show-tables.md)).
- `TRUNCATE TABLE t` empties a table and keeps it (see [UPDATE and DELETE](update-delete.md#truncate)). PostgreSQL counts it as DDL.
**Example: DDL: a table, a column added later, a view and an index** (sample data: sample)
```sql
CREATE TABLE watchlist (symbol STRING PRIMARY KEY, note STRING);
ALTER TABLE watchlist ADD COLUMN target DOUBLE;
CREATE VIEW watchlist_names AS
SELECT w.symbol, s.name, w.target FROM watchlist w JOIN securities s ON s.symbol = w.symbol;
CREATE INDEX watchlist_note ON watchlist (note);
SHOW COLUMNS FROM watchlist;
SHOW VIEWS;
```
Result:
`CREATE TABLE watchlist (`
```text
table 'watchlist' created
```
`ALTER TABLE watchlist ADD COLUMN target DOUBLE`
```text
table 'watchlist' altered
```
`CREATE VIEW watchlist_names AS SELECT w.symbol, s.name, w.target FROM watchlist AS w JOIN securities`
```text
view 'watchlist_names' created
```
`CREATE INDEX watchlist_note ON watchlist (note)`
```text
index 'watchlist_note' created on table 'watchlist' (0 rows)
```
`SHOW COLUMNS FROM watchlist`
| name (STRING) | type (STRING) | primary_key (BOOL) | not_null (BOOL) | computed (BOOL) | references (STRING) |
|---|---|---|---|---|---|
| symbol | STRING | true | false | false | NULL |
| note | STRING | false | false | false | NULL |
| target | DOUBLE | false | false | false | NULL |
(3 rows)
`SHOW VIEWS`
| name (STRING) | query (STRING) | kind (STRING) | refresh (STRING) |
|---|---|---|---|
| watchlist_names | SELECT w.symbol, s.name, w.target FROM watchlist AS w JOIN securities AS s ON s.symbol = w.symbol | complex | NULL |
(1 row)
## DML: Data Manipulation Language
DML statements change the **rows** of tables. The table itself stays as it is.
| Statement | What it does | Help |
|---|---|---|
| `INSERT INTO ... VALUES` | adds rows | [insert](insert.md) |
| `INSERT OR REPLACE`, `INSERT OR IGNORE`, `ON CONFLICT` | adds rows, or replaces / keeps the row with the same key | [insert](insert.md#insert-or-replace) |
| `INSERT INTO ... SELECT` | adds the rows a query gives | [insert](insert.md#insert-select) |
| `UPDATE ... SET ... WHERE` | changes rows | [update-delete](update-delete.md#update) |
| `DELETE FROM ... WHERE` | removes rows | [update-delete](update-delete.md#delete) |
| `... RETURNING` | shows the rows that were added, changed or removed | [update-delete](update-delete.md#returning) |
| `CALL name(...)` | runs a stored procedure, which changes rows | [procedures](procedures.md) |
- **Role:** `write`.
- **Notes:**
- Each statement is all or nothing: an `INSERT` of 5000 rows with one bad row saves no row.
- To make **many** DML statements succeed or fail together, put them inside `BEGIN` ... `COMMIT` (TCL).
- Triggers run by themselves after (or before) DML on their table.
- A view has no rows of its own: DML on a view is refused. Change the table the view reads.
**Example: DML: add, change and remove rows** (sample data: sample)
Setup:
```sql
CREATE TABLE watchlist (symbol STRING PRIMARY KEY, note STRING, target DOUBLE);
```
```sql
INSERT INTO watchlist VALUES ('CITY', 'bank', 520), ('VOLT', 'hydro', 250);
UPDATE watchlist SET target = 540 WHERE symbol = 'CITY';
DELETE FROM watchlist WHERE symbol = 'VOLT' RETURNING symbol, note;
SELECT * FROM watchlist;
```
Result:
`INSERT INTO watchlist VALUES ('CITY', 'bank', 520), ('VOLT', 'hydro', 250)`
```text
2 row(s) inserted
```
`UPDATE watchlist SET target = 540 WHERE symbol = 'CITY'`
```text
1 row(s) updated
```
`DELETE FROM watchlist WHERE symbol = 'VOLT' RETURNING symbol, note`
| symbol (STRING) | note (STRING) |
|---|---|
| VOLT | hydro |
(1 row)
```text
1 row(s) deleted
```
`SELECT * FROM watchlist`
| symbol (STRING) | note (STRING) | target (DOUBLE) |
|---|---|---|
| CITY | bank | 540 |
(1 row)
## DCL: Data Control Language
DCL statements decide **who** may connect and what each user may do.
| Statement | What it does | Help |
|---|---|---|
| `CREATE USER name PASSWORD '...' ROLE ...` | adds a user with a role for every database | [users-and-roles](users-and-roles.md#create-user) |
| `ALTER USER` | changes a password or a role | [users-and-roles](users-and-roles.md#alter-user) |
| `DROP USER` | removes a user | [users-and-roles](users-and-roles.md#drop-user) |
| `GRANT role ON DATABASE db TO name` | gives a role on one database | [users-and-roles](users-and-roles.md#grant) |
| `GRANT ... ON TABLE` | gives rights on one table | [table-rights](table-rights.md) |
| `REVOKE ... FROM name` | takes a grant away | [users-and-roles](users-and-roles.md#revoke) |
| `SHOW USERS` | lists users, roles and grants | [users-and-roles](users-and-roles.md#show-users) |
The roles, from least to most: `none` < `read` < `write` < `admin`. Each role may do everything of the roles before it.
- **Role:** `admin` as the user's **own** `ROLE` (a `GRANT` of admin on one database is not enough), because users belong to the whole server. Changing your own password needs no role.
- **Notes:**
- The **first** user must have `ROLE admin`. From then on, `kdb serve` asks everyone to log in.
- Users are kept in the `_system` database, not in the database you are using.
- Give each program only what it needs: an app's backend `write` on its own database, a report script `read`.
**Example: DCL: an admin, a read-only report user, and an app that may write one database** (sample data: sample)
Setup:
```sql
CREATE USER admin WITH PASSWORD 'Long-Admin-Passw0rd' ROLE admin;
```
```sql
CREATE USER report WITH PASSWORD 'Reports-only-secret-7' ROLE read;
CREATE USER shop_app WITH PASSWORD 'Another-long-secret-42' ROLE none;
GRANT write ON DATABASE demo TO shop_app;
SHOW USERS;
```
Result:
`CREATE USER report WITH PASSWORD '***' ROLE read`
```text
user 'report' created with role read
```
`CREATE USER shop_app WITH PASSWORD '***' ROLE none`
```text
user 'shop_app' created with role none
```
`GRANT write ON DATABASE demo TO shop_app`
```text
user 'shop_app' has role write on database 'demo'
```
`SHOW USERS`
| name (STRING) | role (STRING) | grants (STRING) | created (TIMESTAMP) | changed (TIMESTAMP) | table_grants (STRING) | two_factor (BOOL) | statement_timeout (STRING) | priority (STRING) |
|---|---|---|---|---|---|---|---|---|
| admin | admin | NULL | 2026-10-22T05:44:28.977Z | 2026-10-22T05:44:28.977Z | NULL | false | NULL | NULL |
| report | read | NULL | 2026-10-22T05:44:29.197Z | 2026-10-22T05:44:29.197Z | NULL | false | NULL | NULL |
| shop_app | none | demo=write | 2026-10-22T05:44:29.265Z | 2026-10-22T05:44:29.268Z | NULL | false | NULL | NULL |
(3 rows)
## TCL: Transaction Control Language
TCL statements group DML into one **transaction**: KDB saves all of its changes together, or none of them.
| Statement | What it does | Help |
|---|---|---|
| `BEGIN` (also `BEGIN TRANSACTION`, `BEGIN WORK`) | starts a transaction | [transactions](transactions.md#begin) |
| `COMMIT` | saves every change since `BEGIN`, in one write | [transactions](transactions.md#commit) |
| `ROLLBACK` | throws away every change since `BEGIN` | [transactions](transactions.md#rollback) |
| `SELECT ... FOR UPDATE` | reads rows that no other session may change before you `COMMIT` | [transactions](transactions.md#for-update) |
- **Role:** `read` to start and end one. The statements inside still need their own role (`write` for DML).
- **Notes:**
- Without `BEGIN`, every statement is its own small transaction and is saved at once.
- When a statement inside the transaction fails, KDB undoes the whole transaction.
- `SAVEPOINT`, `ROLLBACK TO SAVEPOINT` and `RELEASE SAVEPOINT` undo part of a transaction ([savepoints](transactions.md#savepoint)).
- One writer at a time: while a transaction changes rows, other writers wait (see [one writer at a time](transactions.md#one-writer)).
**Example: TCL: a transfer that is saved, and a mistake that is undone** (sample data: sample)
Setup:
```sql
CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
```
```sql
BEGIN;
UPDATE accounts SET balance = balance - 300 WHERE id = 1;
UPDATE accounts SET balance = balance + 300 WHERE id = 2;
COMMIT;
BEGIN;
DELETE FROM accounts;
ROLLBACK;
SELECT * FROM accounts ORDER BY id;
```
Result:
`BEGIN`
```text
transaction started
```
`UPDATE accounts SET balance = balance - 300 WHERE id = 1`
```text
1 row(s) updated
```
`UPDATE accounts SET balance = balance + 300 WHERE id = 2`
```text
1 row(s) updated
```
`COMMIT`
```text
transaction committed: 2 change(s) saved
```
`BEGIN`
```text
transaction started
```
`DELETE FROM accounts`
```text
2 row(s) deleted
```
`ROLLBACK`
```text
transaction rolled back: 1 change(s) undone
```
`SELECT * FROM accounts ORDER BY id`
| id (INT) | owner (STRING) | balance (DECIMAL(2)) |
|---|---|---|
| 1 | Ram | 700.00 |
| 2 | Sita | 300.00 |
(2 rows)
## DQL: Data Query Language
DQL statements **read**. They never change a row or an object, so they are safe to run at any time.
| Statement | What it does | Help |
|---|---|---|
| `SELECT` | reads rows: `WHERE`, `JOIN`, `GROUP BY`, `ORDER BY`, `LIMIT` | [select](select.md) |
| `SELECT ... SAMPLE BY`, `LATEST ON` | time-series candles and the newest row per key | [sample-by](sample-by.md), [latest-on](latest-on.md) |
| `WITH ... SELECT` | a query with named parts | [union-subqueries](union-subqueries.md) |
| `EXPLAIN SELECT ...` | shows how KDB will run a query, without running it | [explain](explain.md) |
| `SHOW TABLES`, `SHOW VIEWS`, `SHOW INDEXES`, ... | lists the objects of a database | [show-tables](show-tables.md) |
| `DECLARE`, `FETCH`, `CLOSE` | reads a big result a part at a time | [cursors](cursors.md) |
- **Role:** `read`.
- **Notes:**
- Selecting from a view is DQL too: the view's query runs, and nothing is changed.
- `SELECT ... FOR UPDATE` is the one read that waits for writers; it belongs with TCL.
**Example: DQL: a report, a query plan and a list of objects** (sample data: sample)
```sql
SELECT symbol, count(*) AS trades, sum(quantity) AS shares
FROM trade_log GROUP BY symbol ORDER BY trades DESC, symbol LIMIT 3;
EXPLAIN SELECT * FROM ochl_day WHERE symbol = 'CITY';
SHOW TABLES;
```
Result:
`SELECT symbol, count(*) AS trades, sum(quantity) AS shares FROM trade_log GROUP BY symbol ORDER BY t`
| symbol (SYMBOL) | trades (LONG) | shares (LONG) |
|---|---|---|
| CITY | 11 | 2420 |
| COAST | 11 | 3070 |
| TELCO | 6 | 2770 |
(3 rows)
`EXPLAIN SELECT * FROM ochl_day WHERE symbol = 'CITY'`
| plan (STRING) |
|---|
| scan table ochl_day (2 of 2 partitions, about 57 rows) |
| read only 1 key range(s) of each partition |
| filter while scanning: 1 condition(s) |
| engine: column |
(4 rows)
`SHOW TABLES`
| name (STRING) | kind (STRING) | partition (STRING) | columns (INT) | rows (LONG) |
|---|---|---|---|---|
| brokers | relational | NONE | 2 | 5 |
| live_data | timeseries | DAY | 12 | 12 |
| ochl_day | timeseries | YEAR | 7 | 57 |
| ochl_minutes | timeseries | MONTH | 7 | 956 |
| securities | relational | NONE | 3 | 5 |
| trade_log | timeseries | DAY | 8 | 32 |
(6 rows)
## Which group is my statement?
| Starts with | Group |
|---|---|
| `CREATE`, `ALTER`, `DROP`, `RENAME`, `UNDROP` (tables, views, indexes, functions, procedures, triggers, sequences, databases) | DDL |
| `INSERT`, `UPDATE`, `DELETE`, `CALL` | DML |
| `CREATE USER`, `ALTER USER`, `DROP USER`, `GRANT`, `REVOKE` | DCL |
| `BEGIN`, `COMMIT`, `ROLLBACK` | TCL |
| `SELECT`, `WITH`, `EXPLAIN`, `SHOW`, `DECLARE`, `FETCH`, `CLOSE` | DQL |
Other statements belong to looking after the database, not to one of the five groups: `BACKUP DATABASE`, `CHECK TABLE`, `CHECKPOINT` (see [backup and safety](backup-and-safety.md)) and `USE` (see [databases](databases.md#use)).
---
# Database models: relational, hierarchical, network, NoSQL, object
> The five classic kinds of database, and how much of each KDB can be - with a picture and a working example for each.
- Category: Getting started
- Keywords: data model, database model, database type, kinds of database, relational database, RDBMS, hierarchical database, tree, org chart, parent child, network database, graph, graph database, nodes and edges, many parents, shortest route, non-relational, NoSQL, document database, document store, MongoDB, key-value, key value store, Redis, time-series database, full-text search engine, object-oriented database, OODBMS, object database, inheritance, ORM, WITH RECURSIVE, recursive query
- See also: [Constraints](constraints.md), [Joins](joins.md), [UNION and subqueries](union-subqueries.md), [Column types](column-types.md), [JSON functions](functions-json.md), [INSERT](insert.md), [SAMPLE BY](sample-by.md), [Full-text search](full-text-search.md), [Saved functions](saved-functions.md), [Stored procedures](procedures.md), [PostgreSQL protocol (port 8832)](postgres-protocol.md)
Books sort databases into five **models**, by the shape their data takes. KDB is a **relational** database, but its tables, keys, `JSON` columns and recursive queries let it act like four of the five. Only the **object-oriented** model is missing.
| Model | Data looks like | In KDB | How |
|---|---|---|---|
| [Relational](#relational) | tables linked by keys | **full** - what KDB is | tables, `PRIMARY KEY`, `REFERENCES`, joins, transactions |
| [Hierarchical](#hierarchical) | a tree: one parent each | **yes** | a column that points to its own table, read with `WITH RECURSIVE` |
| [Network](#network) | a web: many parents, loops | **yes** | a table of items and a table of links, read with `WITH RECURSIVE` |
| [Non-relational (NoSQL)](#non-relational) | documents, key-value, time-series, search | **yes**, four kinds | `JSON` columns, `INSERT OR REPLACE`, `SAMPLE BY`, `CREATE FULLTEXT INDEX` |
| [Object-oriented](#object-oriented) | classes with inheritance and methods | **partly** | objects as `JSON`, behaviour in saved functions; no classes |
- All five work through the same SQL, over HTTP, in the web console and on the [PostgreSQL port](postgres-protocol.md). One query may mix them, for example join a tree to a `JSON` column.
- KDB is one server. It is not a cluster that spreads data over many machines.
## Relational
Data lives in **tables** of rows and columns. Tables are linked by **keys**: a column in one table holds the key of a row in another table, and a query joins them back together. Every rule is checked when a row is written, so the data stays correct.
> **Diagram:** Two tables, brokers and trades. The buyer column of trades references the id column of brokers.
>
> A trade can only name a broker that exists. KDB refuses any other row.
| KDB gives you | Help |
|---|---|
| `PRIMARY KEY`, `UNIQUE`, `NOT NULL`, `CHECK`, `DEFAULT` | [constraints](constraints.md) |
| foreign keys: `REFERENCES` | [constraints](constraints.md#references) |
| `JOIN`, subqueries, `UNION`, `GROUP BY` | [joins](joins.md), [union-subqueries](union-subqueries.md) |
| views, saved functions, procedures, triggers | [views](views.md), [procedures](procedures.md), [triggers](triggers.md) |
| `BEGIN`, `COMMIT`, `ROLLBACK` | [transactions](transactions.md) |
- **Good for:** users and accounts, ledgers, lists of companies and brokers - anything with rules that must always hold.
- **Notes:** there is no `ON DELETE CASCADE`: delete the child rows before the parent row.
**Example: A trade must name a real broker**
Setup:
```sql
CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL);
CREATE TABLE trades (id LONG PRIMARY KEY AUTO, symbol SYMBOL, buyer INT NOT NULL REFERENCES brokers(id));
INSERT INTO brokers VALUES (58, 'Northgate Securities'), (45, 'Imperial Securities');
```
```sql
INSERT INTO trades (symbol, buyer) VALUES ('CITY', 58), ('COAST', 45);
SELECT t.id, t.symbol, b.name AS buyer FROM trades t JOIN brokers b ON b.id = t.buyer ORDER BY t.id;
```
Result:
`INSERT INTO trades (symbol, buyer) VALUES ('CITY', 58), ('COAST', 45)`
```text
2 row(s) inserted
```
`SELECT t.id, t.symbol, b.name AS buyer FROM trades AS t JOIN brokers AS b ON b.id = t.buyer ORDER BY`
| id (LONG) | symbol (SYMBOL) | buyer (STRING) |
|---|---|---|
| 1 | CITY | Northgate Securities |
| 2 | COAST | Imperial Securities |
(2 rows)
**Example: A broker that does not exist is refused** (shows an error)
Setup:
```sql
CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL);
CREATE TABLE trades (id LONG PRIMARY KEY AUTO, buyer INT NOT NULL REFERENCES brokers(id));
```
```sql
INSERT INTO trades (buyer) VALUES (99);
```
Result:
Error: `column 'buyer' = '99' has no matching row in brokers(id)`
## Hierarchical
Data forms a **tree**. Every item has **one** parent, and a parent may have many children: an org chart, folders, sectors and sub-sectors, forum replies.
In KDB, give the table a column that holds the parent's key and `REFERENCES` the same table. The top row has `NULL` there. A `WITH RECURSIVE` query then walks the tree: it starts at the top, and adds the children of the rows it found last, again and again, until no new rows come.
> **Diagram:** A tree. CEO at the top; CTO and CFO under the CEO; Dev Lead under the CTO; Developer under the Dev Lead.
>
> Each row keeps one link, to its boss. The recursive query builds the whole tree from those links.
- **Good for:** org charts, categories, folders, comment threads, who referred whom.
- **Notes:** there is no special tree column type (like PostgreSQL's `ltree`). A recursive query stops with an error after 100,000 rounds or 10 million rows, so a mistake cannot run forever.
**Example: An org chart: every person with their level and path**
Setup:
```sql
CREATE TABLE staff (id INT PRIMARY KEY, boss_id INT REFERENCES staff(id), name STRING NOT NULL);
INSERT INTO staff VALUES (1, NULL, 'CEO');
INSERT INTO staff VALUES (2, 1, 'CTO'), (3, 1, 'CFO');
INSERT INTO staff VALUES (4, 2, 'Dev Lead');
INSERT INTO staff VALUES (5, 4, 'Developer');
```
```sql
WITH RECURSIVE tree(id, name, depth, path) AS (
SELECT id, name, 0, name FROM staff WHERE boss_id IS NULL
UNION ALL
SELECT s.id, s.name, t.depth + 1, t.path || ' > ' || s.name
FROM staff s JOIN tree t ON s.boss_id = t.id
)
SELECT id, name, depth, path FROM tree ORDER BY path;
```
Result:
| id (INT) | name (STRING) | depth (LONG) | path (STRING) |
|---|---|---|---|
| 1 | CEO | 0 | CEO |
| 3 | CFO | 1 | CEO > CFO |
| 2 | CTO | 1 | CEO > CTO |
| 4 | Dev Lead | 2 | CEO > CTO > Dev Lead |
| 5 | Developer | 3 | CEO > CTO > Dev Lead > Developer |
(5 rows)
**Example: Everyone under the CTO**
Setup:
```sql
CREATE TABLE staff (id INT PRIMARY KEY, boss_id INT REFERENCES staff(id), name STRING NOT NULL);
INSERT INTO staff VALUES (1, NULL, 'CEO');
INSERT INTO staff VALUES (2, 1, 'CTO'), (3, 1, 'CFO');
INSERT INTO staff VALUES (4, 2, 'Dev Lead');
INSERT INTO staff VALUES (5, 4, 'Developer');
```
```sql
WITH RECURSIVE team(id, name) AS (
SELECT id, name FROM staff WHERE name = 'CTO'
UNION ALL
SELECT s.id, s.name FROM staff s JOIN team t ON s.boss_id = t.id
)
SELECT id, name FROM team ORDER BY id;
```
Result:
| id (INT) | name (STRING) |
|---|---|
| 2 | CTO |
| 4 | Dev Lead |
| 5 | Developer |
(3 rows)
## Network
Like a tree, but an item may have **many** parents, and links may go round in a loop. The data is a web of **nodes** and **links**: roads between cities, share holdings between companies, friends of friends.
In KDB, keep the nodes in one table and the links in a second table with two `REFERENCES` columns. A `WITH RECURSIVE` query follows the links. Give it a stop rule (a maximum number of steps), because a loop in the data would otherwise go round again and again.
> **Diagram:** Four cities and the roads between them. Kathmandu to Pokhara 200 km, Kathmandu to Chitwan 150 km, Chitwan to Butwal 110 km, Pokhara to Butwal 160 km. The shortest way to Butwal is through Chitwan, 260 km.
>
> Butwal has two parents, Pokhara and Chitwan - a tree cannot hold that. The coloured route is the shortest.
- **Good for:** routes, networks of links, who owns shares in whom, recommendations.
- **Notes:** KDB is not a graph database like Neo4j: there is no Cypher language and no built-in shortest-path function. For networks of thousands of links, SQL is enough.
**Example: Every route from Kathmandu to Butwal, shortest first**
Setup:
```sql
CREATE TABLE city (name STRING PRIMARY KEY);
CREATE TABLE road (from_city STRING NOT NULL REFERENCES city(name), to_city STRING NOT NULL REFERENCES city(name), km INT NOT NULL);
INSERT INTO city VALUES ('Kathmandu'), ('Pokhara'), ('Chitwan'), ('Butwal');
INSERT INTO road VALUES ('Kathmandu', 'Pokhara', 200), ('Kathmandu', 'Chitwan', 150), ('Chitwan', 'Butwal', 110), ('Pokhara', 'Butwal', 160);
```
```sql
WITH RECURSIVE route(city, km, hops, path) AS (
SELECT 'Kathmandu', 0, 0, 'Kathmandu'
UNION ALL
SELECT r.to_city, route.km + r.km, route.hops + 1, route.path || ' > ' || r.to_city
FROM road r JOIN route ON r.from_city = route.city
WHERE route.hops < 5
)
SELECT km, hops, path FROM route WHERE city = 'Butwal' ORDER BY km;
```
Result:
| km (LONG) | hops (LONG) | path (STRING) |
|---|---|---|
| 260 | 2 | Kathmandu > Chitwan > Butwal |
| 360 | 2 | Kathmandu > Pokhara > Butwal |
(2 rows)
## Non-relational (NoSQL)
"Non-relational" is not one model but a family. KDB has four members of it, and each one is still a table you can join to the others.
| Kind | Other products like it | In KDB | Help |
|---|---|---|---|
| **document** | MongoDB | a `JSON` column: every row may have different fields | [column-types](column-types.md#json), [functions-json](functions-json.md) |
| **key-value** | Redis | a two-column table with a `PRIMARY KEY`, written with `INSERT OR REPLACE` | [insert](insert.md#insert-or-replace) |
| **time-series** | QuestDB, InfluxDB | a table with a time column, `PARTITION BY`, `SAMPLE BY`, `LATEST ON` | [create-table](create-table.md), [sample-by](sample-by.md) |
| **search** | Elasticsearch | `CREATE FULLTEXT INDEX`, `match`, `search_rank` | [full-text-search](full-text-search.md) |
> **Diagram:** One KDB server with four non-relational kinds: document, key-value, time-series and search, all used through the same SQL.
>
> Not four products: four ways to use one database.
- **Not in KDB:** a wide-column store (like Cassandra), a graph query language, and spreading data over many servers.
- A `JSON` value, and a whole row, can be up to 16 MB. Keep images and PDF files outside the database, and store their path.
**Example: Document: products whose fields differ**
Setup:
```sql
CREATE TABLE products (id INT PRIMARY KEY, doc JSON NOT NULL);
INSERT INTO products VALUES
(1, '{"name": "Laptop", "price": 950, "tags": ["tech", "work"], "specs": {"ram_gb": 16}}'),
(2, '{"name": "Shirt", "price": 20, "size": "M"}');
```
```sql
UPDATE products SET doc = json_set(doc, '$.price', 899) WHERE id = 1;
SELECT id, doc ->> 'name' AS name, json_value_double(doc, '$.price') AS price,
json_value_long(doc, '$.specs.ram_gb') AS ram_gb, doc ->> 'size' AS size
FROM products ORDER BY id;
```
Result:
`UPDATE products SET doc = json_set(doc, '$.price', 899) WHERE id = 1`
```text
1 row(s) updated
```
`SELECT id, doc ->> 'name' AS name, json_value_double(doc, '$.price') AS price, json_value_long(doc, `
| id (INT) | name (STRING) | price (DOUBLE) | ram_gb (LONG) | size (STRING) |
|---|---|---|---|---|
| 1 | Laptop | 899 | 16 | NULL |
| 2 | Shirt | 20 | NULL | M |
(2 rows)
**Example: Key-value: a setting is replaced, not added twice**
Setup:
```sql
CREATE TABLE settings (k STRING PRIMARY KEY, v STRING);
```
```sql
INSERT OR REPLACE INTO settings VALUES ('theme', 'dark');
INSERT OR REPLACE INTO settings VALUES ('theme', 'light'), ('lang', 'ne');
SELECT k, v FROM settings ORDER BY k;
```
Result:
`INSERT OR REPLACE INTO settings VALUES ('theme', 'dark')`
```text
1 row(s) inserted
```
`INSERT OR REPLACE INTO settings VALUES ('theme', 'light'), ('lang', 'ne')`
```text
1 row(s) inserted, 1 replaced
```
`SELECT k, v FROM settings ORDER BY k`
| k (STRING) | v (STRING) |
|---|---|
| lang | ne |
| theme | light |
(2 rows)
**Example: Time-series: 15-minute candles from ticks**
Setup:
```sql
CREATE TABLE ticks (time TIMESTAMP, symbol SYMBOL, price DOUBLE) TIMESTAMP(time) PARTITION BY DAY;
INSERT INTO ticks VALUES
('2026-09-10T11:00:00+05:45', 'CITY', 500), ('2026-09-10T11:05:00+05:45', 'CITY', 510),
('2026-09-10T11:16:00+05:45', 'CITY', 505);
```
```sql
SELECT time, first(price) AS open, max(price) AS high, min(price) AS low, last(price) AS close
FROM ticks SAMPLE BY 15m;
```
Result:
| time (TIMESTAMP) | open (DOUBLE) | high (DOUBLE) | low (DOUBLE) | close (DOUBLE) |
|---|---|---|---|---|
| 2026-09-10T05:15:00.000Z | 500 | 510 | 500 | 510 |
| 2026-09-10T05:30:00.000Z | 505 | 505 | 505 | 505 |
(2 rows)
**Example: Search: news that talks about profit**
Setup:
```sql
CREATE TABLE news (id INT PRIMARY KEY, body STRING NOT NULL);
CREATE FULLTEXT INDEX news_body ON news (body);
INSERT INTO news VALUES (1, 'City Bank profit rises 18%'), (2, 'Hydro power output falls'), (3, 'COAST profit is flat');
```
```sql
SELECT id, body FROM news WHERE match(body, 'profit') ORDER BY id;
```
Result:
| id (INT) | body (STRING) |
|---|---|
| 1 | City Bank profit rises 18% |
| 3 | COAST profit is flat |
(2 rows)
## Object-oriented
An object database stores the objects of a program as they are: **classes**, **inheritance** (`class Car extends Vehicle`) and **methods** kept with the data. KDB does not have classes or inheritance. You can get part of the way:
> **Diagram:** An object database has nested fields, methods and inheritance. In KDB, nested fields become a JSON column and methods become saved functions or procedures; inheritance has no match.
>
> Two of the three parts have a match in KDB. Inheritance does not.
| An object database has | In KDB | Help |
|---|---|---|
| nested fields and lists | a `JSON` column; arrays are kept as JSON lists | [functions-json](functions-json.md), [arrays](functions-postgres.md#arrays) |
| methods | saved functions and stored procedures | [saved-functions](saved-functions.md), [procedures](procedures.md) |
| one clean shape to read | a view | [views](views.md) |
| classes and inheritance | **none**: use one table per class, or a `kind` column | |
| object ids and links between objects | a `PRIMARY KEY` and `REFERENCES` | [constraints](constraints.md) |
- **Tip:** most apps do not use an object database. They map objects to tables with an **ORM** (SQLAlchemy, Prisma, Entity Framework, GORM), which talks to KDB over the [PostgreSQL port](postgres-protocol.md).
**Example: Vehicles of different kinds: shared columns, extra fields as JSON, a function as a method**
Setup:
```sql
CREATE TABLE vehicles (id INT PRIMARY KEY, kind STRING NOT NULL CHECK (kind IN ('car', 'bike')), wheels INT NOT NULL, extra JSON DEFAULT '{}');
CREATE FUNCTION describe(kind STRING, wheels INT) RETURNS STRING AS (kind || ' with ' || wheels || ' wheels');
```
```sql
INSERT INTO vehicles VALUES (1, 'car', 4, '{"doors": 5, "fuel": "petrol"}'), (2, 'bike', 2, '{"gears": 21}');
SELECT id, describe(kind, wheels) AS description, extra ->> 'doors' AS doors, extra ->> 'gears' AS gears
FROM vehicles ORDER BY id;
```
Result:
`INSERT INTO vehicles VALUES (1, 'car', 4, '{"doors": 5, "fuel": "petrol"}'), (2, 'bike', 2, '{"gears`
```text
2 row(s) inserted
```
`SELECT id, describe(kind, wheels) AS description, extra ->> 'doors' AS doors, extra ->> 'gears' AS g`
| id (INT) | description (STRING) | doors (STRING) | gears (STRING) |
|---|---|---|---|
| 1 | car with 4 wheels | 5 | NULL |
| 2 | bike with 2 wheels | NULL | 21 |
(2 rows)
---
# Sample data
> The small made-up stock market dataset that most help examples use, with a few rows of each.
- Category: Getting started
- Keywords: sample, fixture, demo data, example data, stock market, tutorial data, schema.sql, sample-data.sql, securities, brokers, ochl\_day, ochl\_minutes, trade\_log, live\_data, candles, trades
- See also: [Quick start](quick-start.md), [CREATE TABLE](create-table.md), [SAMPLE BY](sample-by.md), [LATEST ON](latest-on.md), [Joins](joins.md)
Most examples in this help use a small **made-up** stock market dataset: companies, brokers, price candles, trades and live prices. The names and prices only look real. It is just one example of what KDB stores; the same SQL works for sensor readings, logs and metrics, accounting, users and logins, or any app's data. The examples call it the fixture `sample`. Every example starts with a fresh copy, so an example that changes rows does not change the next one.
| Table | Kind | Rows | What it holds |
|---|---|---|---|
| `securities` | relational | 5 | companies: CITY, COAST, VOLT, TELCO, ACME |
| `brokers` | relational | 5 | broker numbers and names |
| `ochl_day` | time-series, `PARTITION BY YEAR` | 57 | daily candles of CITY, COAST, VOLT: 4 days of 2025-12 and 15 days of 2026-08 and 2026-09 |
| `ochl_minutes` | time-series, `PARTITION BY MONTH` | 956 | one-minute candles of CITY and COAST on 2026-09-09 and 2026-09-10 |
| `trade_log` | time-series, `PARTITION BY DAY` | 32 | trades on 2026-09-09 and 2026-09-10 |
| `live_data` | time-series, `PARTITION BY DAY` | 12 | live prices of CITY, COAST, VOLT, TELCO at three times on 2026-09-10 |
To load the same data into your own database, see [quick start](quick-start.md#sample-data). The files are `docs/tutorial/examples/setup/schema.sql` and `sample-data.sql`.
**Example: All tables and their row counts** (sample data: sample)
```sql
SHOW TABLES;
```
Result:
| name (STRING) | kind (STRING) | partition (STRING) | columns (INT) | rows (LONG) |
|---|---|---|---|---|
| brokers | relational | NONE | 2 | 5 |
| live_data | timeseries | DAY | 12 | 12 |
| ochl_day | timeseries | YEAR | 7 | 57 |
| ochl_minutes | timeseries | MONTH | 7 | 956 |
| securities | relational | NONE | 3 | 5 |
| trade_log | timeseries | DAY | 8 | 32 |
(6 rows)
## securities
Syntax:
```sql
CREATE TABLE securities (
symbol SYMBOL PRIMARY KEY,
name STRING NOT NULL,
sector STRING
);
```
One row per listed company. `symbol` is the key.
| Column | Type | Meaning |
|---|---|---|
| `symbol` | `SYMBOL` | stock symbol, like `CITY` |
| `name` | `STRING` | company name |
| `sector` | `STRING` | business sector, like `Banking` |
**Example: All companies** (sample data: sample)
```sql
SELECT symbol, name, sector FROM securities ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | name (STRING) | sector (STRING) |
|---|---|---|
| ACME | Acme Investment Company | Investment |
| CITY | City Bank Limited | Banking |
| COAST | Coastal Bank Limited | Banking |
| TELCO | Northwind Telecom Limited | Telecom |
| VOLT | Volt River Hydropower Limited | Energy |
(5 rows)
**Example: Last daily close with the company name (a join)** (sample data: sample)
```sql
SELECT s.symbol, s.name, 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) | close (DOUBLE) |
|---|---|---|
| CITY | City Bank Limited | 504.9 |
| COAST | Coastal Bank Limited | 376.1 |
| VOLT | Volt River Hydropower Limited | 191.2 |
(3 rows)
## brokers
Syntax:
```sql
CREATE TABLE brokers (
id INT PRIMARY KEY,
name STRING NOT NULL
);
```
One row per broker. `trade_log.buyer` and `trade_log.seller` point to `brokers(id)` with `REFERENCES`.
| Column | Type | Meaning |
|---|---|---|
| `id` | `INT` | broker number |
| `name` | `STRING` | broker name |
**Example: All brokers** (sample data: sample)
```sql
SELECT id, name FROM brokers ORDER BY id;
```
Result:
| id (INT) | name (STRING) |
|---|---|
| 1 | Keystone Securities |
| 34 | Vision Securities |
| 45 | Imperial Securities |
| 49 | Online Securities |
| 58 | Northgate Securities |
(5 rows)
## ochl_day
Syntax:
```sql
CREATE TABLE ochl_day (
time TIMESTAMP ROUND 1d,
symbol SYMBOL,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume DOUBLE
) TIMESERIES(time) PARTITION BY YEAR DEDUP KEY (symbol, time);
```
Daily candles (open, high, low, close and volume). `time` is the trading date at 00:00 UTC. `ROUND 1d` cuts every time down to the day, and `DEDUP KEY (symbol, time)` allows one row per symbol per day.
- **Notes:** the data has two parts: 2025-12-28 to 2025-12-31, and 2026-08-23 to 2026-09-10 (trading days of a Sunday-to-Thursday trading week, as on some exchanges). So there are two partitions: `2025` and `2026`.
**Example: Daily candles of one day** (sample data: sample)
```sql
SELECT time, symbol, open, high, low, close, volume FROM ochl_day WHERE time IN '2026-09-10' ORDER BY symbol;
```
Result:
| time (TIMESTAMP) | symbol (SYMBOL) | open (DOUBLE) | high (DOUBLE) | low (DOUBLE) | close (DOUBLE) | volume (DOUBLE) |
|---|---|---|---|---|---|---|
| 2026-09-10T00:00:00.000Z | CITY | 504.6 | 507 | 500.7 | 504.9 | 183040 |
| 2026-09-10T00:00:00.000Z | COAST | 380.1 | 382 | 373.2 | 376.1 | 186200 |
| 2026-09-10T00:00:00.000Z | VOLT | 185.6 | 192.1 | 184.2 | 191.2 | 41470 |
(3 rows)
**Example: How many days per symbol** (sample data: sample)
```sql
SELECT symbol, count(*) AS days, min(time) AS first_day, max(time) AS last_day FROM ochl_day GROUP BY symbol ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | days (LONG) | first_day (TIMESTAMP) | last_day (TIMESTAMP) |
|---|---|---|---|
| CITY | 19 | 2025-12-28T00:00:00.000Z | 2026-09-10T00:00:00.000Z |
| COAST | 19 | 2025-12-28T00:00:00.000Z | 2026-09-10T00:00:00.000Z |
| VOLT | 19 | 2025-12-28T00:00:00.000Z | 2026-09-10T00:00:00.000Z |
(3 rows)
## ochl_minutes
Syntax:
```sql
CREATE TABLE ochl_minutes (
time TIMESTAMP ROUND 1m,
symbol SYMBOL,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume DOUBLE
) TIMESERIES(time) PARTITION BY MONTH DEDUP KEY (symbol, time);
```
One-minute candles. The market is open 11:00 to 15:00 local time (+05:45, Nepal time), which is 05:15 to 09:15 UTC, so a full day has 240 minutes.
- **Notes:** only CITY (480 rows) and COAST (476 rows) have minute candles, on 2026-09-09 and 2026-09-10.
**Example: The first 5 minutes of CITY on 2026-09-10 (11:00 Nepal time)** (sample data: sample)
```sql
SELECT time, symbol, open, high, low, close, volume
FROM ochl_minutes
WHERE symbol = 'CITY' AND time >= '2026-09-10T11:00:00+05:45'
ORDER BY time
LIMIT 5;
```
Result:
| time (TIMESTAMP) | symbol (SYMBOL) | open (DOUBLE) | high (DOUBLE) | low (DOUBLE) | close (DOUBLE) | volume (DOUBLE) |
|---|---|---|---|---|---|---|
| 2026-09-10T05:15:00.000Z | CITY | 504.6 | 504.6 | 504.4 | 504.4 | 960 |
| 2026-09-10T05:16:00.000Z | CITY | 504.4 | 504.7 | 504.3 | 504.6 | 700 |
| 2026-09-10T05:17:00.000Z | CITY | 504.6 | 504.9 | 504 | 504.2 | 150 |
| 2026-09-10T05:18:00.000Z | CITY | 504.2 | 504.6 | 504.1 | 504.4 | 1050 |
| 2026-09-10T05:19:00.000Z | CITY | 504.4 | 504.8 | 504.1 | 504.5 | 450 |
(5 rows)
**Example: Minute candles turned into hourly candles** (sample data: sample)
```sql
SELECT time, first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close, sum(volume) AS volume
FROM ochl_minutes
WHERE symbol = 'COAST' AND time IN '2026-09-09'
SAMPLE BY 1h;
```
Result:
| time (TIMESTAMP) | open (DOUBLE) | high (DOUBLE) | low (DOUBLE) | close (DOUBLE) | volume (DOUBLE) |
|---|---|---|---|---|---|
| 2026-09-09T05:00:00.000Z | 398 | 398.8 | 392.2 | 392.9 | 33000 |
| 2026-09-09T06:00:00.000Z | 392.9 | 393.6 | 387.4 | 387.7 | 43470 |
| 2026-09-09T07:00:00.000Z | 387.7 | 389.2 | 381.6 | 382.3 | 49820 |
| 2026-09-09T08:00:00.000Z | 382.3 | 384.2 | 379.2 | 380 | 49500 |
| 2026-09-09T09:00:00.000Z | 380 | 380.9 | 379.4 | 380.1 | 10540 |
(5 rows)
## trade_log
Syntax:
```sql
CREATE TABLE trade_log (
date TIMESTAMP ROUND 1d,
transaction_id LONG,
symbol SYMBOL,
buyer INT REFERENCES brokers(id),
seller INT REFERENCES brokers(id),
quantity LONG,
rate DOUBLE,
amount DOUBLE AS (quantity * rate)
) TIMESERIES(date) PARTITION BY DAY DEDUP KEY (date, transaction_id);
```
Every trade of the day: who bought, who sold, how many shares and at what rate. `amount` is a **computed column**: it is not stored, KDB works it out from `quantity * rate` when you read it.
- **Notes:** a broker that trades point to cannot be deleted from `brokers`.
**Example: The first trades of 2026-09-10 with broker names** (sample data: sample)
```sql
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 b.id = f.buyer
JOIN brokers s ON s.id = f.seller
WHERE f.date IN '2026-09-10'
ORDER BY f.transaction_id
LIMIT 5;
```
Result:
| transaction_id (LONG) | symbol (SYMBOL) | buyer (STRING) | seller (STRING) | quantity (LONG) | rate (DOUBLE) | amount (DOUBLE) |
|---|---|---|---|---|---|---|
| 100009 | COAST | Vision Securities | Imperial Securities | 50 | 400 | 20000 |
| 100010 | CITY | Keystone Securities | Imperial Securities | 50 | 517 | 25850 |
| 100011 | VOLT | Keystone Securities | Northgate Securities | 50 | 184 | 9200 |
| 100012 | VOLT | Northgate Securities | Imperial Securities | 200 | 184 | 36800 |
| 100013 | COAST | Northgate Securities | Keystone Securities | 50 | 398 | 19900 |
(5 rows)
**Example: Turnover per symbol** (sample data: sample)
```sql
SELECT symbol, count(*) AS trades, sum(quantity) AS shares, sum(amount) AS turnover
FROM trade_log
GROUP BY symbol
ORDER BY turnover DESC;
```
Result:
| symbol (SYMBOL) | trades (LONG) | shares (LONG) | turnover (DOUBLE) |
|---|---|---|---|
| TELCO | 6 | 2770 | 2438260 |
| CITY | 11 | 2420 | 1247230 |
| COAST | 11 | 3070 | 1227510 |
| VOLT | 4 | 400 | 73950 |
(4 rows)
## live_data
Syntax:
```sql
CREATE TABLE live_data (
timestamp TIMESTAMP,
symbol SYMBOL,
ltp DOUBLE,
ltv LONG,
point_change DOUBLE,
pct_change DOUBLE,
open DOUBLE,
high DOUBLE,
low DOUBLE,
avg_traded_price DOUBLE,
prev_close DOUBLE,
volume LONG
) TIMESERIES(timestamp) PARTITION BY DAY DEDUP KEY (timestamp, symbol);
```
Snapshots of live market prices: last traded price (`ltp`), last traded volume (`ltv`), change from the previous close, and the day's open, high, low and volume so far.
- **Notes:** the snapshots are at 11:05, 13:00 and 14:59 Nepal time on 2026-09-10. The time column is called `timestamp`; that word is not reserved.
**Example: The latest price of every symbol** (sample data: sample)
```sql
SELECT timestamp, symbol, ltp, pct_change, volume
FROM live_data
LATEST ON timestamp PARTITION BY symbol
ORDER BY symbol;
```
Result:
| timestamp (TIMESTAMP) | symbol (SYMBOL) | ltp (DOUBLE) | pct_change (DOUBLE) | volume (LONG) |
|---|---|---|---|---|
| 2026-09-10T09:14:00.000Z | CITY | 527.1 | 2.71 | 75490 |
| 2026-09-10T09:14:00.000Z | COAST | 401.9 | 0.7 | 35210 |
| 2026-09-10T09:14:00.000Z | TELCO | 897.7 | 1.9 | 69340 |
| 2026-09-10T09:14:00.000Z | VOLT | 184 | -0.86 | 55940 |
(4 rows)
**Example: All snapshots of TELCO** (sample data: sample)
```sql
SELECT timestamp, ltp, point_change, pct_change FROM live_data WHERE symbol = 'TELCO' ORDER BY timestamp;
```
Result:
| timestamp (TIMESTAMP) | ltp (DOUBLE) | point_change (DOUBLE) | pct_change (DOUBLE) |
|---|---|---|---|
| 2026-09-10T05:20:00.000Z | 860.5 | -20.5 | -2.33 |
| 2026-09-10T07:15:00.000Z | 869 | -12 | -1.36 |
| 2026-09-10T09:14:00.000Z | 897.7 | 16.7 | 1.9 |
(3 rows)
---
# 1. Install KDB
- Category: Tutorial
## What you will learn
- Three ways to get KDB: an install package, the Docker image, or a build from the source.
- How to build KDB on Windows, Linux and macOS.
- Where KDB keeps your databases: the data folder and `KDB_DATA_DIR`.
- Where the web console, the help and the Claude skill come from: the folders `web/`, `help/` and `skill/`.
- What each program in `build/bin` does.
- How to run the tests.
## Three ways to get KDB
| Way | Good for | Section |
|---|---|---|
| An install package (`.zip` for Windows, `.tar.gz` for Linux) | a PC or a server that only runs KDB | [Install a package](#install-a-package) |
| The Docker image | a server that already runs Docker | [Run KDB in Docker](#run-kdb-in-docker) |
| Build from the source | working on KDB itself, and this tutorial | [Build on Windows](#build-on-windows-msys2) |
## The program and its folders
KDB is one program, `kdb` (`kdb.exe` on Windows). Nothing else is put inside it. The web console, the help pages and the Claude skill are plain files in folders **next to the program**:
```text
kdb.exe the program
kdb.conf settings (optional); the console's Settings page writes it
web\ the web console
help\ the help pages
skill\skill.md the Claude skill: teaches an AI assistant in another project how to use KDB
```
So a new console or a new help needs only the files copied, not a new program. `kdb` looks for each folder in this order. The first one that has the folder's files wins:
| # | Where | Example |
|---|---|---|
| 1 | `web_dir`, `help_dir`, `skill_dir` in `kdb.conf` | `"help_dir": "D:/kdb/help"` |
| 2 | The environment variables `KDB_WEB_DIR`, `KDB_HELP_DIR`, `KDB_SKILL_DIR` | `$env:KDB_HELP_DIR = "D:\kdb\help"` |
| 3 | `web`, `help`, `skill` next to the program | `C:\kdb\web` |
| 4 | `../share/kdb/web`, `help`, `skill` (Linux: `/usr/local/bin` and `/usr/local/share/kdb`) | `/usr/local/share/kdb/web` |
| 5 | The source folders, when you run `build\bin\kdb.exe` in a source checkout: `web\console`, `docs\help\site`, `.claude\skills\kdb` | `D:\KDB\web\console` |
A missing folder is not an error. The server still runs; the page says the console (or the help) is not installed, and where KDB looked. The console's **Settings** page shows the folders in use.
## Install a package
A package holds the program, the `web`, `help` and `skill` folders, an installer and `kdb.conf.example`. No source and no tests.
**Windows** (`kdb--windows-x64.zip`): unpack it, open PowerShell in the folder and run:
```powershell
powershell -ExecutionPolicy Bypass -File .\install.ps1
```
It copies `kdb.exe` with its folders to `%LOCALAPPDATA%\Programs\kdb`, puts it on your `PATH` and writes the Claude skill. To remove KDB, delete that folder.
**Linux** (`kdb--linux-x64.tar.gz`, a static program that runs on any distribution):
```sh
./install.sh # just for you: ~/.local/bin and ~/.local/share/kdb
sudo ./install.sh --system # for everyone: /usr/local/bin and /usr/local/share/kdb
sudo ./install.sh --system --service --token # and start it at boot (systemd)
```
To make the packages yourself from the source, see `packaging/README.md`.
## Run KDB in Docker
The image is built from the source folder. It puts the program in `/usr/local/bin` and copies the `web`, `help` and `skill` folders into `/usr/local/share/kdb/`. The help pages are copied only when they were built first (`node tools/help/build_help.mjs`, see the README of this tutorial); without them `/help` says the help is not installed.
```sh
docker build -t kdb -f docker/Dockerfile .
docker run -d --name kdb --restart unless-stopped --stop-timeout 60 \
-p 9900:9900 -p 8832:8832 \
-e KDB_TOKEN= -e KDB_MEMORY=1536 \
-v kdb-data:/data kdb
```
| Setting | Meaning |
|---|---|
| `-p 9900:9900` | the HTTP API and the web console |
| `-p 8832:8832` | the PostgreSQL protocol (chapter 14) |
| `KDB_TOKEN` | the access token; send it as `Authorization: Bearer `. **Required**: the container listens on all addresses, and KDB never does that without a secret, so it stops at once when `KDB_TOKEN` is not set. |
| `KDB_MEMORY` | memory in MB for table data (`--memory`; 256 when not set) |
| `-v kdb-data:/data` | the data folder. It lives outside the container, so a new image does not lose it. `kdb.conf` is kept there too. |
| `--stop-timeout 60` | lets KDB stop cleanly. Without it, Docker may kill KDB after a few seconds, and the next start replays the log. |
| `--restart unless-stopped` | starts KDB again after a restart of Docker or the computer |
To update: build the image again, then `docker stop kdb`, `docker rm kdb` and the same `docker run`. The data stays in the volume. Stop the container before you remove it: `docker rm -f` kills KDB at once.
`docker exec kdb kdb skill > SKILL.md` saves the Claude skill from the running container.
## Build from the source
### What you need
| Tool | Version | Why |
|---|---|---|
| CMake | 3.16 or newer | Makes the build files |
| Ninja | any | Runs the build fast |
| C++ compiler | C++17 (GCC, Clang or MSVC) | Compiles KDB |
KDB needs **no other libraries**. The build makes only the program. The console, the help and the skill stay as files in the source folder, and `build\bin\kdb.exe` finds them there (row 5 of the table above).
## Build on Windows (MSYS2)
1. Install MSYS2 from .
2. Open the **MSYS2 UCRT64** terminal. Install the tools:
```sh
pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-ninja
```
3. Add `C:\msys64\ucrt64\bin` to your Windows `PATH`. Then the tools work in PowerShell too.
4. Open PowerShell in the KDB folder and build:
```powershell
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
```
The programs are in `build\bin\`. They are linked statically. You can copy `kdb.exe` to another Windows PC. It needs no DLL files. For the web console and the help on that PC, copy the folders too (`web\console` as `web`, `docs\help\site` as `help`, `.claude\skills\kdb` as `skill`), or make a package (`packaging/README.md`), which does this for you.
To build only the `kdb` program (faster):
```powershell
cmake --build build --target kdb
```
## Build on Linux
Ubuntu or Debian:
```sh
sudo apt install cmake ninja-build g++
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
```
The programs are in `build/bin/` (no `.exe` at the end).
## Build on macOS
```sh
xcode-select --install
brew install cmake ninja
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
```
## Check the build
```powershell
.\build\bin\kdb.exe version
```
```text
kdb 0.2.0-dev (crc32c hardware: yes)
Stored-format features this build reads (26):
views 0.2.0-dev (2026-09-10) CREATE VIEW: the "views" file
partition_format_2 0.2.0-dev (2026-09-11) table partition files in format 2 (one raw block per column)
block_stats 0.2.0-dev (2026-09-11) column stats section in partition files (count, min, max, sum per column)
runs 0.2.0-dev (2026-09-11) log record 'C': rows of a bulk upsert kept as columns
auto_meta 0.2.0-dev (2026-09-11) AUTO id counters in the manifest (auto.)
json 0.2.0-dev (2026-09-11) JSON columns (type code 12)
bytes 0.2.0-dev (2026-09-11) BYTES columns (type code 13)
check 0.2.0-dev (2026-09-11) CHECK constraints (catalog format 2, part 'C')
indexes 0.2.0-dev (2026-09-11) CREATE INDEX, UNIQUE and FULLTEXT indexes (catalog part 'I')
routines 0.2.0-dev (2026-09-11) functions, procedures and triggers: the "routines" file
users 0.2.0-dev (2026-09-11) server users and roles (_system.users)
audit 0.2.0-dev (2026-09-12) SET AUDIT ON: catalog part 'A', log record 'P', the _audit_log table
table_grants 0.2.0-dev (2026-09-12) GRANT ... ON TABLE (_system.users.table_grants)
totp 0.2.0-dev (2026-09-12) two-factor logins (_system.users.totp)
trash 0.2.0-dev (2026-09-12) dropped tables kept as _dropped__ for UNDROP TABLE
database_settings 0.2.0-dev (2026-09-12) ALTER DATABASE ... SET READ ONLY (_system.database_settings)
dialects 0.2.0-dev (2026-09-12) ALTER DATABASE ... SET dialect (_system.database_dialects)
pg_catalog_parts 0.2.0-dev (2026-09-12) PostgreSQL column types, multi-column keys, foreign keys, expression and partial indexes (catalog parts 'T', 'K', 'F', 'X')
sequences 0.2.0-dev (2026-09-12) CREATE SEQUENCE: the "sequences" file and its log records
batched_log_sync 0.2.0-dev (2026-09-11) --sync-commits batch: log segments with 16-byte commit records
dedup_change 0.2.0-dev (2026-09-12) ALTER TABLE ... DEDUP ENABLE / DISABLE: the DEDUP KEY of a table that already holds rows
plpgsql_triggers 0.2.0-dev (2026-09-12) PostgreSQL plpgsql trigger functions and FOR EACH ROW triggers in the "routines" file
cached_views 0.2.0-dev (2026-09-13) CREATE CACHED VIEW: which views are cached, and REFRESH ON COMMIT, in the "views" file
deferred_triggers 0.2.0-dev (2026-09-13) CREATE TRIGGER ... DEFERRED: job records in the log, and the "trigger_queue" and "q/" files of queued jobs
materialized_views 0.2.0-dev (2026-09-13) CREATE MATERIALIZED VIEW: catalog part 'M', REFRESH MATERIALIZED VIEW log records, mv_dirty. in the manifest
table_ttl 0.2.0-dev (2026-09-13) TTL n DAYS on a table: catalog part 'L'
A database that uses a feature not in this list needs a newer KDB (kdb check exits with 3).
```
`crc32c hardware: yes` means your CPU can compute checksums fast. `no` also works, only slower.
The list under it is the **stored-format feature list**: everything this build can read from disk. Chapter 24 explains it.
Run `kdb` with no command to see all commands:
```powershell
kdb
"exit code: $LASTEXITCODE"
```
```text
kdb 0.2.0-dev
Usage:
kdb info show files, versions and log position
kdb check read every block and log record, report damage
kdb repair [--backups ] [--accept-log-loss]
fix damaged files from old versions or backups
kdb backup [--hard-links] [--keep-daily N] [--keep-weekly N]
kdb backups list backups, newest first
kdb verify-backup
kdb restore [--logs-from ] [--until "YYYY-MM-DD HH:MM[:SS]"]
kdb dump-file header and block checksums of one file
kdb serve [] [--db ] [--port 9900] [--bind 127.0.0.1] [--backups ] [--token ] [--memory ]
[--sync-commits always|batch] [--threads N] HTTP API, line protocol (POST /write) and web console
[--pg-port 8832] [--pg-timestamptz] PostgreSQL protocol for Postgres drivers and tools (0 = off)
(Ctrl+C stops). Without : every database in the data folder
kdb shell [] [--backups ] [--memory ] interactive SQL prompt (end statements with ;)
kdb sql "SQL" [--format table|csv|json] [--max-rows N]
run SQL and print results (use - to read SQL from stdin)
kdb user add [--role admin|write|read] add a server user; the password is asked for (or read
from a pipe), never given on the command line
kdb user passwd set a new password
kdb user role admin|write|read|none change the role
kdb user list users and roles (never passwords)
kdb user remove
kdb user reset-2fa turn two-factor login off (a lost phone)
(kdb user works on the data folder while kdb serve is stopped;
while it runs, use CREATE USER ... in its console)
kdb version [] this build and the stored-format features it reads;
with : the features that database uses
kdb upgrade [--backups ] verified backup, then save the database's feature list, then check
kdb help [ | tutorial []] the help topics (and, in a source checkout, the tutorial)
in the terminal; without a topic: the list of topics
kdb skill [install] [--dir ] [--url ] [--token ] [--home ]
print, or install into ~/.claude/skills/kdb, the file that
teaches an AI assistant in another project how to use KDB
: a database name in the data folder (market), or the path of a database folder (D:\kdb\market, ./market).
Data folder: --data-dir , else kdb.conf, else the KDB_DATA_DIR environment variable,
else "data" next to kdb.
--config : settings the console's Settings page may change (default: kdb.conf next to kdb).
It holds data_dir and wal_archive_mb; a --data-dir on the command line always wins over it. It may also
name web_dir, help_dir and skill_dir: the folders of the console, the help and the Claude skill
(else KDB_WEB_DIR, KDB_HELP_DIR, KDB_SKILL_DIR, else web/, help/ and skill/ next to kdb).
--memory : memory for table data kept loaded (default 512). More is faster on big tables.
--threads : threads for big queries and writes (default and most: half of the CPU threads).
--cache-mb : memory for results of repeated queries (default 64, 0 = off). A result is used
again only while no table it read has changed; queries with now() or random() are never kept.
--reader-yield on|off: readers do not wait for a long trigger or CALL; they read the last
committed rows (default off; env KDB_READER_YIELD).
--warm-indexes on|off: build every index right after a database opens, on a background thread,
so the first query finds them ready (default on; env KDB_WARM_INDEXES). Off: on first use.
--sync-commits always: every write is on disk before it is confirmed (default).
--sync-commits batch: writes are confirmed at once and put on disk every 10 ms. Much faster for
many small writes; a power cut can lose the last confirmed writes, but never damages the data.
Protection of kdb serve (docs/tutorial/21-protection.md):
--statement-timeout 60s stop statements that run longer (default 60s, 0 = no limit)
--max-result-memory 1GB rows one query may keep in memory (default 1GB, 0 = no limit)
--max-groups N GROUP BY groups of one query (default 5000000, 0 = no limit)
--max-queries-per-user N statements one user runs at the same time (default 32, 0 = no limit)
--safe-updates refuse UPDATE and DELETE without WHERE (SET safe_updates = off per session)
--read-only the whole server only reads
--trash-days N DROP TABLE keeps the table N days for UNDROP TABLE (default 7; also sql, shell)
--audit on|off audit log of logins, users, DDL, backups in _system (default on)
--audit-keep-days N remove audit rows older than N days (default 0 = keep forever)
--activity-log-size N recent events for the console's activity panel, in memory (default 5000, 0 = off)
--drain-timeout 30s on stop, running requests may finish this long; new ones get 503 (default 30s)
--backup-schedule "daily 16:00" verified backups of every database (local time; needs --backups)
--backup-copy a second, checked copy of each scheduled backup (another disk)
--backup-warn-hours N warn in the log when the last good backup is older (default 26)
--keep-daily N --keep-weekly N backups kept (default 7 and 4)
Exit codes: 0 OK, 1 error, 2 damage found, 3 the database needs a newer KDB (nothing was changed).
exit code: 1
```
## Where your data lives
A **database** is a folder. KDB keeps many databases in one **data folder**, one sub-folder for each database:
```text
D:\KDB\build\bin\kdb.exe
D:\KDB\build\bin\data\ <- the data folder
D:\KDB\build\bin\data\market\ <- the database "market"
D:\KDB\build\bin\data\demo\ <- the database "demo"
D:\KDB\build\bin\data\_trash\ <- dropped databases (KDB never deletes them)
```
Each database has its own tables, views, triggers and procedures.
KDB looks for the data folder in this order. The first one that is set wins:
| # | Where | Example |
|---|---|---|
| 1 | The option `--data-dir ` | `kdb serve --data-dir E:\kdb-data` |
| 2 | `data_dir` in `kdb.conf` (next to the program, or the file named by `--config`). The console's **Settings** page writes it. | `"data_dir": "E:/kdb-data"` |
| 3 | The environment variable `KDB_DATA_DIR` | `$env:KDB_DATA_DIR = "E:\kdb-data"` |
| 4 | The folder `data` next to the `kdb` program | `D:\KDB\build\bin\data` |
KDB creates the folder when it is missing. Use `KDB_DATA_DIR` when the data must be on another disk, or when `kdb.exe` is in a folder where you cannot write (like `C:\Program Files`).
To set `KDB_DATA_DIR` for good on Windows (then open a new PowerShell window):
```powershell
[Environment]::SetEnvironmentVariable("KDB_DATA_DIR", "E:\kdb-data", "User")
```
On Linux and macOS, add `export KDB_DATA_DIR=/srv/kdb-data` to `~/.bashrc` (or `~/.zshrc`).
Database names: letters, digits, `_` and `-`, 1 to 64 characters. `Market` and `market` are the same database.
An example. Here the data folder is `kdb-data` in the current folder. `kdb shell` without a database name opens the database `default`:
```powershell
$env:KDB_DATA_DIR = "kdb-data"
"CREATE DATABASE market; CREATE DATABASE demo; SHOW DATABASES;" | kdb shell
Get-ChildItem kdb-data -Name
```
```text
KDB 0.2.0-dev shell on default (data folder kdb-data)
End statements with ';'. Type .help for commands, .exit to quit.
kdb> database 'market' created
database 'demo' created
name | tables | bytes | current
--------+--------+-------+--------
default | 0 | 0 | true
demo | 0 | 0 | false
market | 0 | 0 | false
(3 rows, 0.1 ms)
kdb>
default
demo
market
```
Every command also still takes a **folder path**, like in the rest of this tutorial: `kdb sql market "..."` uses the folder `market` in the current folder, as long as the data folder has no database called `market`. [Chapter 7](07-command-line-and-scripts.md#database-names-and-folder-paths) explains names and paths.
## What the programs are
| Program | What it is |
|---|---|
| `kdb` | The main program. It runs SQL, the shell, the server, backups and repairs. You need only this one. |
| `test_env`, `test_crc32c`, `test_block_file`, `test_wal`, `test_manifest`, `test_store`, `test_backup`, `test_disk_full` | Tests for the storage layer. Each tests one part. |
| `test_sql_parser`, `test_catalog`, `test_database`, `test_tables`, `test_select`, `test_indexes`, `test_procedures`, `test_mviews`, ... | Tests for SQL, tables, queries, indexes, procedures and views. |
| `test_table_locks`, `test_txn_readers`, `test_accounting` | Tests for many writers and readers at the same time, and for transactions. |
| `test_manager`, `test_cross_database` | Tests for many databases: names, the data folder, CREATE/DROP DATABASE, the trash folder, queries over two databases. |
| `test_server`, `test_pg`, `test_pg_dml`, `test_users`, `test_protection` | Tests for the HTTP server, the PostgreSQL protocol, users and the protection limits. |
| `powercut_test [n]` | Cuts the "power" at a random moment, `n` times (default 300). Every committed change must still be there. |
| `corrupt_test [n]` | Damages a random file, `n` times (default 1000). KDB must repair it or refuse clearly. It must never return wrong data. |
| `crash_test [n]` | Kills a real writer process at a random moment, `n` times (default 200). |
| `fuzz_test`, `stress_test`, `test_text_fuzz` | Random SQL and random load, to find crashes. |
| `kdb_bench` | Speed tests. See `bench/README.md`. |
## Run the tests
Each test is a separate program. Exit code `0` means it passed. Tests write temporary files into the folder in `KDB_TEST_TMP` (default: `.\kdb_test_tmp`).
```powershell
$env:KDB_TEST_TMP = "D:\kdb_test_tmp"
.\build\bin\test_crc32c.exe
.\build\bin\test_sql_parser.exe
.\build\bin\test_tables.exe
.\build\bin\test_select.exe
"exit code: $LASTEXITCODE"
```
```text
crc32c hardware: yes
PASS test_crc32c
PASS test_sql_parser
bulk: 12272 rows after 20,000 upserts and range deletes
checkpoints on threads: same files as on one thread
idle checkpoint: written after writes stopped
runs: 16094 rows after 160 random steps, both engines equal to the model
row builders: same rows and messages as InsertRows
PASS test_tables
warning: D:\kdb_test_tmp/select/db: the table data of this database is 0 MB, 154869 times the memory budget of 0 MB (--memory). Expect queries over much of it, and writes into old partitions, to read files again and again and to take seconds. Give KDB more memory: --memory 0 or more (KDB_MEMORY in Docker)
warning: D:\kdb_test_tmp/select/db: the table data of this database is 0 MB, 153700 times the memory budget of 0 MB (--memory). Expect queries over much of it, and writes into old partitions, to read files again and again and to take seconds. Give KDB more memory: --memory 0 or more (KDB_MEMORY in Docker)
PASS test_select
exit code: 0
```
The two warnings of `test_select` are on purpose. The test opens a database with a memory budget of 1 byte, to check that queries give the same rows when KDB must read the files again and again. A real server prints the same warning once, when it opens a database whose data is more than 8 times its memory budget (`--memory`).
To run all tests:
```powershell
ctest --test-dir build --output-on-failure
```
There are 56 tests. This takes a long time, because `powercut_test`, `corrupt_test`, `crash_test`, `fuzz_test` and `stress_test` repeat many times (`corrupt_test` alone takes about half an hour). Most others take a few seconds. Run the tests for the part you changed first; run them all before a release. When all pass, the last lines are:
```text
100% tests passed, 0 tests failed out of 56
```
Use a smaller `n` for a quick check:
```powershell
.\build\bin\corrupt_test.exe 50
```
On Linux and macOS:
```sh
export KDB_TEST_TMP=/tmp/kdb_test
./build/bin/test_select
ctest --test-dir build --output-on-failure
```
## Phones (iOS and Android)
For iOS and Android, CMake turns off the `kdb` program and the tests. Only the library is built. This tutorial is about the `kdb` program, so it does not cover phones.
## Try it yourself
1. Build only the `kdb` program again, and check the version it prints.
2. Run `test_catalog` and check the exit code.
3. Run `crash_test` with only 5 rounds.
Answers
1. ```powershell
cmake --build build --target kdb
.\build\bin\kdb.exe version
```
2. ```powershell
.\build\bin\test_catalog.exe
$LASTEXITCODE # 0 = passed
```
3. ```powershell
.\build\bin\crash_test.exe 5
```
---
Next: [2. Your first database](02-first-database.md)
---
# 2. Your first database
- Category: Tutorial
## What you will learn
- What is inside a KDB database folder.
- How to use `kdb shell` (type SQL) and `kdb sql` (run SQL once).
- What the exit codes mean.
The examples use the database `market` with the sample data (see the quick start).
## A database is a folder
A KDB database is **one folder**. You choose the name. When you open a folder that does not exist, KDB creates a new empty database there.
```powershell
Get-ChildItem market -Name
```
```text
files
wal
wal_archive
_trash
kdb.lock
_manifest.a
_manifest.b
```
| Name | What it is |
|---|---|
| `files\` | The data files. One file for each table part (partition), plus the table list (catalog). |
| `wal\` | The write-ahead log. Every change goes here first. |
| `wal_archive\` | Old log files. Only backups and repairs use them. The oldest are deleted after 1 GB (changeable: `ALTER DATABASE ... SET WAL_ARCHIVE`, or `wal_archive_mb` in `kdb.conf` for all databases). |
| `_trash\` | Old file versions. KDB deletes them after 1 hour (`trash_keep_hours` in `kdb.conf`, or `KDB_TRASH_KEEP_HOURS`). |
| `kdb.lock` | Only one program may open the database. This file is locked while it is open. |
| `_manifest.a`, `_manifest.b` | The list of current files. It is saved twice, for safety. |
Rules:
- **Do not change or delete files in the folder by hand.** Use `kdb` commands.
- To copy a database, stop every program that uses it first. Better: use `kdb backup` (chapter 9).
- Only **one program** can open a database at a time. While `kdb serve` runs, `kdb sql` on the same folder fails with "the database is open in another program". Use the server's HTTP API then (chapter 8).
## kdb shell: type SQL
`kdb shell ` opens a prompt. Type SQL and end it with `;`. A statement can use many lines. The prompt `...>` means "the statement is not finished yet".
```text
PS D:\kdb-work> kdb shell market
KDB 0.2.0-dev shell on market
End statements with ';'. Type .help for commands, .exit to quit.
kdb> .help
.tables list tables
.schema show CREATE TABLE
.format table|csv|json
.maxrows rows shown per query (0 = all)
.check read every file and report damage
.backup make a verified backup (needs --backups)
.exit quit
kdb> .tables
name | kind | partition | columns | rows
-------------+------------+-----------+---------+-----
brokers | relational | NONE | 2 | 5
trade_log | timeseries | DAY | 8 | 32
live_data | timeseries | DAY | 12 | 12
ochl_day | timeseries | YEAR | 7 | 57
ochl_minutes | timeseries | MONTH | 7 | 956
securities | relational | NONE | 3 | 5
(6 rows, 0.0 ms)
kdb> SELECT symbol, close
...> FROM ochl_day
...> WHERE time IN '2026-09-10';
symbol | close
-------+------
CITY | 504.9
COAST | 376.1
VOLT | 191.2
(3 rows, 0.4 ms)
kdb> .format csv
kdb> .schema brokers
create_sql
"CREATE TABLE brokers (
id INT PRIMARY KEY,
name STRING NOT NULL
)"
kdb> SELECT id, name FROM brokers LIMIT 3;
id,name
1,Keystone Securities
34,Vision Securities
45,Imperial Securities
kdb> .exit
```
### Shell commands
Shell commands start with a dot. They do **not** need `;`.
| Command | What it does |
|---|---|
| `.help` | Shows the commands |
| `.tables` | Lists the tables (same as `SHOW TABLES`) |
| `.schema ` | Shows the `CREATE TABLE` statement of a table |
| `.format table` / `csv` / `json` | How results are printed. `table` is the default. |
| `.maxrows ` | Shows at most `n` rows per query (default 1000). `0` = all rows. |
| `.check` | Reads every file and reports damage |
| `.backup` | Makes a backup. Start the shell with `--backups ` first. |
| `.exit` or `exit` | Quits the shell. `Ctrl+C` also works. |
Tips:
- In `table` format, long text is cut after 48 characters (`...`). Use `.format csv` to see all of it, for example for `.schema`.
- Keywords and names do not care about case: `select` = `SELECT`, `OCHL_DAY` = `ochl_day`.
- Comments: `-- to the end of the line` and `/* inside */`.
## kdb sql: run SQL once
`kdb sql "SQL"` opens the database, runs the SQL, prints the result and closes the database. This is good for scripts.
```powershell
kdb sql market "SELECT count(*) AS minute_rows FROM ochl_minutes"
```
```text
minute_rows
-----------
956
(1 row, 0.5 ms)
```
### Many statements
Separate statements with `;`. KDB prints one result for each statement, with an empty line between them.
```powershell
kdb sql market "SELECT count(*) AS securities FROM securities; SELECT count(*) AS brokers FROM brokers"
```
```text
securities
----------
5
(1 row, 0.2 ms)
brokers
-------
5
(1 row, 0.1 ms)
```
If one statement fails, KDB **stops** there. The statements before it are already saved. The statements after it do not run. See [chapter 4](04-writing-data.md#a-script-stops-at-the-first-error).
### SQL from a file
Write the SQL in a file. Then send the file into `kdb sql` with a pipe. The `-` means "read the SQL from the pipe".
```powershell
@"
-- report.sql: closing prices on the last day
SELECT symbol, close
FROM ochl_day
WHERE time IN '2026-09-10'
ORDER BY close DESC;
"@ | Set-Content report.sql -Encoding UTF8
Get-Content report.sql -Raw | kdb sql market -
```
```text
symbol | close
-------+------
CITY | 504.9
COAST | 376.1
VOLT | 191.2
(3 rows, 0.2 ms)
```
- Use `-Raw`. It sends the file as one piece of text.
- If the file has text in another language (not English), see [chapter 7](07-command-line-and-scripts.md#non-english-text-utf-8).
- In `cmd.exe` and bash you can write `kdb sql market - < report.sql`.
### Options of kdb sql
| Option | Meaning |
|---|---|
| `--format table` | Text table (default) |
| `--format csv` | CSV, good for Excel and scripts |
| `--format json` | JSON, good for programs |
| `--max-rows N` | At most N rows per statement. Default: 1000 for `table`, no limit for `csv` and `json`. |
| `--backups ` | Backup folder, needed for `BACKUP DATABASE` |
Chapter 7 shows the formats in detail.
## Exit codes
Every `kdb` command ends with an exit code. Scripts use it to know if the command worked.
| Code | Meaning | Examples |
|---|---|---|
| `0` | OK | The query ran. The backup was made. No damage found. |
| `1` | Error | Wrong SQL. Duplicate key. The database is open in another program. Wrong arguments. |
| `2` | Damage | `kdb check` found damage. A file could not be read. A repair could not fix everything. |
| `3` | Needs a newer KDB | The database uses a stored-format feature this `kdb` does not know. Nothing was changed. See chapter 24. |
In PowerShell the exit code is in `$LASTEXITCODE`:
```powershell
kdb sql market "SELECT count(*) AS n FROM brokers"
"exit code: $LASTEXITCODE"
kdb sql market "SELECT count(*) FROM brokerz"
"exit code: $LASTEXITCODE"
```
```text
n
-
5
(1 row, 0.2 ms)
exit code: 0
error: line 1, column 22: table or view 'brokerz' does not exist
exit code: 1
```
Error messages say **where** the problem is: `line 1, column 22` is the place of `brokerz` in the SQL. In bash the exit code is in `$?`.
`kdb shell` ends with the exit code of the last statement or command you ran.
## Try it yourself
1. Open the shell on `market` and list the columns of `live_data`.
2. With one `kdb sql` command, count the rows of `trade_log` and `live_data`.
3. Run `kdb sql market "SELECT * FROM nothing"`. What is the exit code?
Answers
1. ```text
kdb> SHOW COLUMNS FROM live_data;
```
(or `.schema live_data` after `.format csv`)
2. ```powershell
kdb sql market "SELECT count(*) AS trades FROM trade_log; SELECT count(*) AS snapshots FROM live_data"
```
The answers are 32 and 12.
3. The exit code is `1`, because the table does not exist.
---
Next: [3. Tables](03-tables.md)
---
# 3. Tables
- Category: Tutorial
## What you will learn
- The two kinds of tables, and when to use each.
- Column types and column rules (keys, NOT NULL, DEFAULT, REFERENCES, computed columns).
- How to change and remove tables.
The examples start with an **empty** database `market`. Run them in order.
## Two kinds of tables
| | Time-series table | Relational table |
|---|---|---|
| What it holds | Rows with a time: prices, candles, trades | Lists of things: securities, brokers, accounts |
| How you make it | `CREATE TABLE ... TIMESERIES(time_column)` | `CREATE TABLE ...` (nothing extra) |
| Must have | A `TIMESTAMP` or `DATE` column | nothing |
| Split into parts | Yes: `PARTITION BY DAY`, `MONTH`, `YEAR` or `NONE` | No, one part |
| Duplicate check | `DEDUP KEY (...)`, must include the time column | `PRIMARY KEY` |
| Special queries | `SAMPLE BY`, `LATEST ON`, `ASOF JOIN`, `time IN '2026-09'` | normal SQL |
| Stock market examples | `ochl_day`, `ochl_minutes`, `trade_log`, `live_data` | `securities`, `brokers`, `accounts` |
Simple rule: **if the row has a time and you get many new rows every day, use a time-series table.**
## Relational tables
```sql
CREATE TABLE securities (
symbol SYMBOL PRIMARY KEY,
name STRING NOT NULL,
sector STRING
);
CREATE TABLE brokers (
id INT PRIMARY KEY,
name STRING NOT NULL
);
INSERT INTO securities VALUES
('CITY', 'City Bank Limited', 'Banking'),
('COAST', 'Coastal Bank Limited', 'Banking'),
('VOLT', 'Volt River Hydropower Limited', 'Energy');
INSERT INTO brokers VALUES (1, 'Keystone Securities'), (45, 'Imperial Securities'), (58, 'Northgate Securities');
```
```text
table 'securities' created
table 'brokers' created
3 row(s) inserted
3 row(s) inserted
```
(To run a file like this: `Get-Content tables.sql -Raw | kdb sql market -`.)
## Time-series tables
```sql
CREATE TABLE ochl_day (
time TIMESTAMP ROUND 1d,
symbol SYMBOL,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume DOUBLE
) TIMESERIES(time) PARTITION BY YEAR DEDUP KEY (symbol, time);
CREATE TABLE ochl_minutes (
time TIMESTAMP ROUND 1m,
symbol SYMBOL,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume DOUBLE
) TIMESERIES(time) PARTITION BY MONTH DEDUP KEY (symbol, time);
```
```text
table 'ochl_day' created
table 'ochl_minutes' created
```
### The table options
| Option | Meaning | Default |
|---|---|---|
| `TIMESERIES(col)` | This is a time-series table. `col` is its time column (`TIMESTAMP` or `DATE`). | - (without it: relational table) |
| `PARTITION BY DAY` / `MONTH` / `YEAR` / `NONE` | Rows are saved in one file per day, month or year. | `DAY` |
| `PARTITION BY MONTH, SYMBOL(symbol)` | Also one file per symbol (the time part is `YEAR`, `MONTH` or `NONE`). A query for one symbol reads only its files. | - |
| `TTL 90 DAYS` | KDB drops partitions older than this by itself (see [Keep only recent data](#keep-only-recent-data)). | no TTL |
| `SORT BY (a, b)` | Order of rows inside each file. | the first `SYMBOL` column, then the time column |
| `DEDUP KEY (a, b)` | Two rows with the same values here are "the same row". | none: duplicates are allowed |
### A picture of a time-series table
`ochl_minutes` with `PARTITION BY MONTH` and the default `SORT BY (symbol, time)`:
| Partition (one file) | Rows inside the file, in order |
|---|---|
| `2026-08` | CITY 08-30 11:00, CITY 08-30 11:01, ... COAST 08-30 11:00, COAST 08-30 11:01, ... |
| `2026-09` | CITY 09-09 11:00, CITY 09-09 11:01, ... CITY 09-10 14:59, COAST 09-09 11:00, ... |
| `2026-10` | (made when the first October row arrives) |
Why this is good:
- A query with `WHERE time IN '2026-09'` reads only the `2026-09` file.
- A query for one symbol finds its rows close together.
- You can delete a whole month fast: `ALTER TABLE ochl_minutes DROP PARTITION '2026-08'`, or everything older than a year: `ALTER TABLE ochl_minutes DROP PARTITION OLDER THAN 1 YEAR`.
### Which PARTITION BY to use
| Data | Rows per day (all symbols) | Good choice |
|---|---|---|
| Daily candles (`ochl_day`) | a few hundred | `YEAR` |
| Minute candles (`ochl_minutes`) | tens of thousands | `MONTH` |
| TradeLog trades | tens of thousands | `DAY` |
| Live price snapshots | many | `DAY` |
| Very small tables | few | `NONE` |
Partitions are based on the **UTC** date of the time column.
### DEDUP KEY: how duplicates are found
`DEDUP KEY (symbol, time)` means: one row per symbol per time. A second row with the same symbol and time is a duplicate.
- `INSERT` stops with an error on a duplicate.
- `INSERT OR REPLACE` overwrites the old row.
- `INSERT OR IGNORE` keeps the old row and skips the new one.
The DEDUP KEY **must include the time column**:
```sql
CREATE TABLE bad (time TIMESTAMP, symbol SYMBOL, close DOUBLE) TIMESERIES(time) DEDUP KEY (symbol);
```
```text
error: DEDUP KEY must include the time column 'time', e.g. DEDUP KEY (symbol, time)
```
Without a DEDUP KEY, KDB keeps every row, also exact copies:
```sql
CREATE TABLE ticks (time TIMESTAMP, symbol SYMBOL, price DOUBLE) TIMESERIES(time) PARTITION BY DAY;
INSERT INTO ticks VALUES ('2026-09-10T11:00:00+05:45', 'CITY', 510);
INSERT INTO ticks VALUES ('2026-09-10T11:00:00+05:45', 'CITY', 510);
SELECT time, symbol, price FROM ticks;
```
```text
table 'ticks' created
1 row(s) inserted
1 row(s) inserted
time | symbol | price
-------------------------+--------+------
2026-09-10T05:15:00.000Z | CITY | 510
2026-09-10T05:15:00.000Z | CITY | 510
(2 rows, 0.1 ms)
```
**Tip:** give every market-data time-series table a DEDUP KEY. Then you can load the same file twice without making double rows.
### ROUND: clean times
`ROUND 1d` (or `1m`, `5m`, `1h`, ...) on a `TIMESTAMP` column cuts the time down when a row is written. Units: `ms`, `s`, `m` (minute), `h`, `d`, `w`.
```sql
INSERT INTO ochl_day VALUES ('2026-09-10 14:37:12', 'CITY', 504.6, 507, 500.7, 504.9, 183040);
INSERT INTO ochl_minutes VALUES ('2026-09-10T11:05:42+05:45', 'CITY', 504.6, 504.9, 504.5, 504.8, 1200);
SELECT time, symbol, close FROM ochl_day;
SELECT time, symbol, close FROM ochl_minutes;
```
```text
1 row(s) inserted
1 row(s) inserted
time | symbol | close
-------------------------+--------+------
2026-09-10T00:00:00.000Z | CITY | 504.9
(1 row, 0.1 ms)
time | symbol | close
-------------------------+--------+------
2026-09-10T05:20:00.000Z | CITY | 504.8
(1 row, 0.0 ms)
```
- `14:37:12` became `00:00:00` (the day, in UTC).
- `11:05:42` Nepal time is `05:20:42` UTC. It became `05:20:00`.
Because of ROUND, a second row for the same day is a duplicate, even with another time of day:
```sql
INSERT INTO ochl_day VALUES ('2026-09-10 09:00', 'CITY', 1, 1, 1, 1, 1);
```
```text
error: a row with symbol='CITY', time='2026-09-10T00:00:00.000Z' already exists in 'ochl_day'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it
```
**Careful:** `ROUND 1d` rounds to the **UTC** day. For daily candles stored at 00:00 UTC of the trading date, this is right. A time like `2026-09-10T02:00:00+05:45` (still 9 September in UTC) would go to 9 September.
## SHOW: look at tables
```sql
SHOW TABLES;
```
```text
name | kind | partition | columns | rows
-------------+------------+-----------+---------+-----
brokers | relational | NONE | 2 | 3
trade_log | timeseries | DAY | 8 | 0
live_data | timeseries | DAY | 5 | 0
ochl_day | timeseries | YEAR | 7 | 0
ochl_minutes | timeseries | MONTH | 7 | 0
securities | relational | NONE | 3 | 3
(6 rows, 0.0 ms)
```
(`trade_log` and `live_data` are created in the next section. The output above was taken after that.)
```sql
SHOW COLUMNS FROM trade_log;
```
```text
name | type | primary_key | not_null | computed | references
---------------+-----------+-------------+----------+----------+------------
date | TIMESTAMP | false | false | false | NULL
transaction_id | LONG | false | false | false | NULL
symbol | SYMBOL | false | false | false | NULL
buyer | INT | false | false | false | brokers(id)
seller | INT | false | false | false | brokers(id)
quantity | LONG | false | false | false | NULL
rate | DOUBLE | false | false | false | NULL
amount | DOUBLE | false | false | true | NULL
(8 rows, 0.0 ms)
```
`SHOW CREATE TABLE` gives the full statement. Use CSV format, so the long text is not cut:
```powershell
kdb sql market "SHOW CREATE TABLE ochl_day" --format csv
```
```text
create_sql
"CREATE TABLE ochl_day (
time TIMESTAMP ROUND 1d,
symbol SYMBOL,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume DOUBLE
) TIMESERIES(time) PARTITION BY YEAR SORT BY (symbol, time) DEDUP KEY (symbol, time)"
```
You can see the default `SORT BY (symbol, time)` here.
`SHOW VIEWS` lists views (chapter 5).
## Column types
| Type | Size | Values | Use it for |
|---|---|---|---|
| `BOOL` | 1 byte | `TRUE`, `FALSE` | yes/no flags |
| `BYTE` | 1 byte | -127 to 127 | very small numbers |
| `SHORT` | 2 bytes | -32767 to 32767 | small numbers |
| `INT` | 4 bytes | -2147483647 to 2147483647 | broker ids |
| `LONG` | 8 bytes | about ±9.2 × 10^18 | transaction ids, share quantities |
| `FLOAT` | 4 bytes | about 7 digits | rarely needed |
| `DOUBLE` | 8 bytes | about 15 digits | prices, volumes, percentages |
| `DECIMAL(s)` | 8 bytes | exact, `s` digits after the point (0 to 18) | money: `DECIMAL(2)` for dollars and cents |
| `DATE` | 4 bytes | a day, like `2026-09-10` | dates without time |
| `TIMESTAMP` | 8 bytes | a moment in UTC, in milliseconds | candle and trade times |
| `SYMBOL` | 4 bytes | text that repeats a lot | stock symbols, sectors |
| `STRING` | as long as the text | any text (UTF-8) | names, notes |
Other names that also work: `BOOLEAN` = `BOOL`, `INTEGER` = `INT`, `BIGINT` = `LONG`, `VARCHAR` and `TEXT` = `STRING`.
There are more types for other kinds of data: `JSON` and `BYTES` (chapter 18), and the PostgreSQL types like `SERIAL`, `VARCHAR(n)`, `NUMERIC(p,s)`, `UUID` and arrays (chapter 22).
Every type can be `NULL` (no value). For the number types, the smallest value is used to store NULL. That is why `BYTE` goes to -127, not -128.
All types in one table:
```sql
CREATE TABLE type_demo (
b BOOL,
i8 BYTE,
i16 SHORT,
i32 INT,
i64 LONG,
f FLOAT,
d DOUBLE,
m DECIMAL(2),
day DATE,
ts TIMESTAMP,
sym SYMBOL,
txt STRING
);
INSERT INTO type_demo VALUES
(TRUE, 127, 32767, 2147483647, 9223372036854775807, 1.5, 512.45, 1500.25,
'2026-09-10', '2026-09-10T11:00:00+05:45', 'CITY', 'City Bank Limited'),
(NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
SELECT * FROM type_demo;
```
```text
table 'type_demo' created
2 row(s) inserted
b | i8 | i16 | i32 | i64 | f | d | m | day | ts | sym | txt
-----+------+-------+------------+---------------------+------+--------+---------+------------+--------------------------+-------+-------------------
true | 127 | 32767 | 2147483647 | 9223372036854775807 | 1.5 | 512.45 | 1500.25 | 2026-09-10 | 2026-09-10T05:15:00.000Z | CITY | City Bank Limited
NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL
(2 rows, 0.1 ms)
```
**DOUBLE or DECIMAL?** `DOUBLE` is fast and good for prices and indicators. It is not exact: `0.1 + 0.2` is not exactly `0.3`. For money that must add up exactly (accounts, fees), use `DECIMAL(2)`.
## Column rules
| Rule | Meaning |
|---|---|
| `PRIMARY KEY` | One row per value. At most one per table. Cannot be NULL. |
| `PRIMARY KEY AUTO` | Like PRIMARY KEY. If you give no value, KDB uses the next number of the table's counter. The counter only goes up: ids of deleted rows are never used again. Integer columns only. |
| `NOT NULL` | The column must have a value. |
| `UNIQUE` | No two rows may have the same value (NULL is allowed many times). |
| `DEFAULT value` | Used when the INSERT gives no value. A constant, or `now()`. |
| `REFERENCES table(column)` | The value must exist in the other table. The other column must be PRIMARY KEY or UNIQUE. |
| `AS (expression)` | A computed column. It is not stored; KDB computes it when you read. |
| `ROUND interval` | Cuts TIMESTAMP values down (see above). |
Use PRIMARY KEY on **relational** tables. On time-series tables use DEDUP KEY (a PRIMARY KEY there is refused).
### Accounts: AUTO key, NOT NULL, UNIQUE, DEFAULT, DECIMAL
```sql
CREATE TABLE accounts (
id LONG PRIMARY KEY AUTO,
owner STRING NOT NULL,
email STRING UNIQUE,
balance DECIMAL(2) DEFAULT 0,
status STRING DEFAULT 'active',
opened TIMESTAMP DEFAULT now()
);
INSERT INTO accounts (owner, email, balance) VALUES
('Ram Sharma', 'ram@example.com', 1500.25),
('Sita Rai', 'sita@example.com', '99.5');
INSERT INTO accounts (owner) VALUES ('Hari Thapa');
SELECT id, owner, email, balance, status FROM accounts;
```
```text
table 'accounts' created
2 row(s) inserted
1 row(s) inserted
id | owner | email | balance | status
---+------------+------------------+---------+-------
1 | Ram Sharma | ram@example.com | 1500.25 | active
2 | Sita Rai | sita@example.com | 99.50 | active
3 | Hari Thapa | NULL | 0.00 | active
(3 rows, 0.1 ms)
```
- `id` was filled with 1, 2, 3.
- `'99.5'` (text) was turned into the decimal `99.50`.
- Hari got the DEFAULT values `0.00` and `active`.
The rules stop bad rows:
```sql
INSERT INTO accounts (owner, balance) VALUES ('Gita', 10.555);
```
```text
error: column 'balance' (DECIMAL(2)): 10.555 has more than 2 decimal places
```
```sql
INSERT INTO accounts (email) VALUES ('nobody@example.com');
```
```text
error: column 'owner' (STRING): cannot be NULL
```
```sql
INSERT INTO accounts (owner, email) VALUES ('Ram Again', 'ram@example.com');
```
```text
error: column 'email' must be unique: 'ram@example.com' is already used
```
### TradeLog: REFERENCES and a computed column
```sql
CREATE TABLE trade_log (
date TIMESTAMP ROUND 1d,
transaction_id LONG,
symbol SYMBOL,
buyer INT REFERENCES brokers(id),
seller INT REFERENCES brokers(id),
quantity LONG,
rate DOUBLE,
amount DOUBLE AS (quantity * rate)
) TIMESERIES(date) PARTITION BY DAY DEDUP KEY (date, transaction_id);
CREATE TABLE live_data (
timestamp TIMESTAMP,
symbol SYMBOL,
ltp DOUBLE,
pct_change DOUBLE,
volume LONG
) TIMESERIES(timestamp) PARTITION BY DAY DEDUP KEY (timestamp, symbol);
```
```text
table 'trade_log' created
table 'live_data' created
```
Insert a trade. Do not give `amount`; KDB computes it:
```sql
INSERT INTO trade_log (date, transaction_id, symbol, buyer, seller, quantity, rate)
VALUES ('2026-09-10', 100001, 'CITY', 58, 45, 100, 515.5);
SELECT transaction_id, symbol, buyer, seller, quantity, rate, amount FROM trade_log;
```
```text
1 row(s) inserted
transaction_id | symbol | buyer | seller | quantity | rate | amount
---------------+--------+-------+--------+----------+-------+-------
100001 | CITY | 58 | 45 | 100 | 515.5 | 51550
(1 row, 0.2 ms)
```
A broker that does not exist is refused:
```sql
INSERT INTO trade_log (date, transaction_id, symbol, buyer, seller, quantity, rate)
VALUES ('2026-09-10', 100002, 'COAST', 99, 45, 10, 399);
```
```text
error: column 'buyer' = '99' has no matching row in brokers(id)
```
A broker that trades are pointing at cannot be deleted:
```sql
DELETE FROM brokers WHERE id = 58;
```
```text
error: cannot remove brokers(id) = '58': table 'trade_log' column 'buyer' still refers to it (foreign key 'trade_log_buyer_fkey')
```
**Speed note:** REFERENCES checks every new row. For very big imports (millions of trade_log rows) you may want to leave REFERENCES out, and check the data with a query instead.
## Change a table
| Statement | What it does |
|---|---|
| `ALTER TABLE t ADD COLUMN c TYPE [rules]` | Adds a column at the end. Old rows get NULL. |
| `ALTER TABLE t RENAME COLUMN a TO b` | Renames a column. |
| `ALTER TABLE t DROP COLUMN c` | Removes a column and its data. |
| `ALTER TABLE t ALTER COLUMN c TYPE LONG` | Changes the type of a column. Every row is checked first: when one value does not fit, nothing changes. |
| `ALTER TABLE t DEDUP ENABLE UPSERT KEYS (time, symbol)` | Gives a table a DEDUP KEY later. Rows with the same key are folded into one: the newest row is kept. `DEDUP DISABLE` takes the key away. |
| `ALTER TABLE t DROP PARTITION '2025', '2026-01'` | Deletes whole partitions (chapter 4). |
| `ALTER TABLE t DROP PARTITION OLDER THAN 6 MONTHS` | Deletes the partitions that ended more than 6 months ago. |
| `ALTER TABLE t SET TTL 90 DAYS` / `SET TTL NONE` | Drops old partitions by itself from now on (see below). |
| `TRUNCATE TABLE t` | Removes all rows. The table, its columns and indexes stay. |
| `RENAME TABLE a TO b` | Renames a table. |
| `DROP TABLE t` / `DROP TABLE IF EXISTS t` | Deletes a table and all its data. It is kept in the trash for 7 days, so `UNDROP TABLE t` can bring it back (chapter 21). |
| `CREATE TABLE IF NOT EXISTS t (...)` | Creates the table only if it is not there yet. |
The word `COLUMN` is optional: `ALTER TABLE t ADD c LONG` also works.
```sql
ALTER TABLE ticks ADD COLUMN volume LONG;
ALTER TABLE ticks RENAME COLUMN price TO ltp;
INSERT INTO ticks VALUES ('2026-09-10T11:01:00+05:45', 'CITY', 511, 300);
SELECT time, symbol, ltp, volume FROM ticks;
```
```text
table 'ticks' altered
table 'ticks' altered
1 row(s) inserted
time | symbol | ltp | volume
-------------------------+--------+-----+-------
2026-09-10T05:15:00.000Z | CITY | 510 | NULL
2026-09-10T05:15:00.000Z | CITY | 510 | NULL
2026-09-10T05:16:00.000Z | CITY | 511 | 300
(3 rows, 0.2 ms)
```
Some columns cannot be dropped: the TIMESERIES column, SORT BY and DEDUP KEY columns, the PRIMARY KEY, columns that other tables REFERENCE, and columns used by a computed column.
```sql
ALTER TABLE ticks DROP COLUMN symbol;
```
```text
error: cannot drop column 'symbol' from table 'ticks': it is part of SORT BY
```
```sql
ALTER TABLE ticks DROP COLUMN volume;
RENAME TABLE ticks TO test_ticks;
DROP TABLE test_ticks;
DROP TABLE IF EXISTS test_ticks;
SHOW TABLES;
```
```text
table 'ticks' altered
table 'ticks' renamed to 'test_ticks'
table 'test_ticks' dropped; UNDROP TABLE test_ticks brings it back within 7 days
table 'test_ticks' does not exist
name | kind | partition | columns | rows
-------------+------------+-----------+---------+-----
accounts | relational | NONE | 6 | 3
brokers | relational | NONE | 2 | 3
trade_log | timeseries | DAY | 8 | 1
live_data | timeseries | DAY | 5 | 0
ochl_day | timeseries | YEAR | 7 | 1
ochl_minutes | timeseries | MONTH | 7 | 1
securities | relational | NONE | 3 | 3
type_demo | relational | NONE | 12 | 2
(8 rows, 0.0 ms)
```
A table that another table REFERENCES cannot be dropped:
```sql
DROP TABLE brokers;
```
```text
error: cannot drop table 'brokers': column 'buyer' of table 'trade_log' REFERENCES it; drop that column or table first
```
### Keep only recent data
Live prices and ticks grow every day. Often you need only the last months. A **TTL** (time to live) drops old partitions by itself:
```sql
ALTER TABLE live_data SET TTL 90 DAYS;
```
```text
table 'live_data' drops partitions older than 90 DAYS
```
- KDB checks once a minute. A partition goes when its **last** moment is older than the TTL. It never deletes single rows.
- `SHOW CREATE TABLE` shows the TTL. `ALTER TABLE live_data SET TTL NONE` turns it off.
- You can also give it when you create the table: `... TIMESERIES(timestamp) PARTITION BY DAY TTL 90 DAYS`.
- To drop old data once, by hand or from a timer, use `ALTER TABLE live_data DROP PARTITION OLDER THAN 90 DAYS`.
### What you cannot change
You cannot change the **PARTITION BY** or the **SORT BY** of a table, and you cannot change the type of a column that is the time column or part of SORT BY or DEDUP KEY. To change them, create a new table, copy the rows with `INSERT ... SELECT`, then drop the old table:
```sql
CREATE TABLE ochl_day_by_symbol (time TIMESTAMP, symbol SYMBOL, close DOUBLE) TIMESERIES(time) PARTITION BY YEAR SORT BY (symbol, time);
INSERT INTO ochl_day_by_symbol SELECT time, symbol, close FROM ochl_day;
```
Copying a big table this way takes time and space. **Choose the table design carefully before you load big data.**
## Names
- Table and column names: letters, digits and `_`. They do not care about upper or lower case.
- A name that is a reserved word (like `order`, `from`, `select`) must be written in double quotes: `"order"`. Chapter 11 lists the reserved words.
- `time`, `date`, `timestamp`, `symbol` and `partition` are **not** reserved. You can use them as column names.
## Try it yourself
1. Create a time-series table `index_minutes` for stock index values: time, index name, value. One row per index per minute. Partition by month.
2. Create a relational table `sectors` with `name` as the primary key. Add a new column `sector_name` to `securities` that must point to `sectors(name)`.
3. Insert a stock index value at `2026-09-10T11:00:00+05:45` and again at `11:00:30` into your table from task 1, with plain `INSERT`. What happens?
Answers
1. ```sql
CREATE TABLE index_minutes (
time TIMESTAMP ROUND 1m,
index_name SYMBOL,
value DOUBLE
) TIMESERIES(time) PARTITION BY MONTH DEDUP KEY (index_name, time);
```
2. ```sql
CREATE TABLE sectors (name STRING PRIMARY KEY);
ALTER TABLE securities ADD COLUMN sector_name STRING REFERENCES sectors(name);
```
Old rows get NULL, which is allowed. A value that is not in `sectors` is refused.
3. `ROUND 1m` makes both times `05:15:00` UTC. The second INSERT fails:
`a row with index_name='MARKET', time='2026-09-10T05:15:00.000Z' already exists`. Use `INSERT OR REPLACE` to keep the newest value.
---
Next: [4. Writing data](04-writing-data.md)
---
# 4. Writing data
- Category: Tutorial
## What you will learn
- How to insert, replace, update and delete rows.
- How to write times, and how KDB stores them (UTC).
- A safe way to fix bad data. (This is the main reason KDB was made.)
The examples use the database `market` with the sample data. They change the data, so run them in order.
## Every change is saved at once
When KDB says `1 row(s) inserted`, the change is already on the disk. There is no `COMMIT` to type. A power cut after that message does not lose the row (chapter 9 explains how).
**One statement is all or nothing.** If an INSERT with 5000 rows has one bad row, **no** row is saved.
## INSERT
Insert many rows in one statement. It is much faster than one statement per row.
```sql
INSERT INTO ochl_day (time, symbol, open, high, low, close, volume) VALUES
('2026-09-13', 'CITY', 505.0, 509.0, 503.1, 508.2, 91000),
('2026-09-13', 'COAST', 376.0, 379.9, 374.0, 378.5, 120500),
('2026-09-13', 'VOLT', 191.0, 194.2, 190.1, 193.7, 64000);
SELECT time, symbol, close FROM ochl_day WHERE time IN '2026-09-13';
```
```text
3 row(s) inserted
time | symbol | close
-------------------------+--------+------
2026-09-13T00:00:00.000Z | CITY | 508.2
2026-09-13T00:00:00.000Z | COAST | 378.5
2026-09-13T00:00:00.000Z | VOLT | 193.7
(3 rows, 0.2 ms)
```
- The column list `(time, symbol, ...)` is optional. Without it, give a value for every column in table order (computed columns are skipped).
- Columns you do not list get their DEFAULT value, or NULL.
- Text and times go in single quotes. A `'` inside text is written twice: `'City''s'`.
- Numbers can also be given as text (`'99.5'`). KDB converts them to the column type.
A second row with the same DEDUP KEY `(symbol, time)` is refused:
```sql
INSERT INTO ochl_day VALUES ('2026-09-13', 'CITY', 505.0, 509.0, 503.1, 508.2, 91000);
```
```text
error: a row with symbol='CITY', time='2026-09-13T00:00:00.000Z' already exists in 'ochl_day'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it
```
## INSERT OR REPLACE
The close price for CITY on 2026-09-13 was wrong. Send the full, correct row again:
```sql
INSERT OR REPLACE INTO ochl_day VALUES ('2026-09-13', 'CITY', 505.0, 509.0, 503.1, 507.6, 91000);
SELECT time, symbol, close FROM ochl_day WHERE symbol = 'CITY' AND time IN '2026-09-13';
```
```text
0 row(s) inserted, 1 replaced
time | symbol | close
-------------------------+--------+------
2026-09-13T00:00:00.000Z | CITY | 507.6
(1 row, 0.2 ms)
```
- A new key: the row is inserted.
- A key that exists: the **whole old row** is replaced by the new row. Columns you do not give become DEFAULT or NULL, not the old values. To change only some columns, use UPDATE.
This is the best statement for **imports**: you can run the same import file again and again. You never get double rows.
## INSERT OR IGNORE
Keeps the old row and skips the new one:
```sql
INSERT OR IGNORE INTO ochl_day VALUES
('2026-09-13', 'CITY', 1, 1, 1, 1, 1),
('2026-09-14', 'CITY', 507.6, 512.0, 506.0, 511.0, 64000);
SELECT time, symbol, close FROM ochl_day WHERE symbol = 'CITY' AND time >= '2026-09-13';
```
```text
1 row(s) inserted, 1 skipped (already there)
time | symbol | close
-------------------------+--------+------
2026-09-13T00:00:00.000Z | CITY | 507.6
2026-09-14T00:00:00.000Z | CITY | 511
(2 rows, 0.2 ms)
```
## How duplicates are found
| Table | What makes two rows "the same" | Without it |
|---|---|---|
| Relational with `PRIMARY KEY` | the same primary key value | - |
| Relational without a key | nothing | every INSERT adds rows |
| Time-series with `DEDUP KEY (a, time)` | the same values in all DEDUP KEY columns | - |
| Time-series without DEDUP KEY | nothing | every INSERT adds rows, also exact copies |
| Any column with `UNIQUE` | the same value in that column | - |
| Statement | Key is new | Key exists |
|---|---|---|
| `INSERT` | inserted | **error**, nothing is saved |
| `INSERT OR REPLACE` | inserted | old row replaced |
| `INSERT OR IGNORE` | inserted | new row skipped |
Duplicates **inside one statement** are found too: with INSERT OR REPLACE, the last one wins.
## UPDATE
`UPDATE table SET column = value, ... WHERE condition`. The new value can use the old values of the row.
```sql
UPDATE ochl_day SET volume = 95000 WHERE symbol = 'CITY' AND time IN '2026-09-13';
UPDATE live_data SET ltp = round(ltp, 0), point_change = round(point_change, 0) WHERE symbol = 'TELCO';
SELECT timestamp, symbol, ltp, point_change FROM live_data WHERE symbol = 'TELCO';
```
```text
1 row(s) updated
3 row(s) updated
timestamp | symbol | ltp | point_change
-------------------------+--------+-----+-------------
2026-09-10T05:20:00.000Z | TELCO | 861 | -21
2026-09-10T07:15:00.000Z | TELCO | 869 | -12
2026-09-10T09:14:00.000Z | TELCO | 898 | 17
(3 rows, 0.1 ms)
```
**Warning:** `UPDATE ... ` without `WHERE` changes **every row** of the table. Chapter 9 shows how to go back in time after such a mistake.
An UPDATE may change the time or key columns. KDB moves the row. But it refuses to make a duplicate:
```sql
UPDATE ochl_day SET time = '2026-09-13' WHERE symbol = 'CITY' AND time IN '2026-09-14';
```
```text
error: the update would create a second row with symbol='CITY', time='2026-09-13T00:00:00.000Z' in 'ochl_day'
```
Computed columns (`AS (...)`) cannot be set.
## DELETE
`DELETE FROM table WHERE condition`. Without WHERE it deletes every row.
```sql
SELECT count(*) AS rows_before FROM ochl_day WHERE time IN '2026-09-14';
DELETE FROM ochl_day WHERE time IN '2026-09-14';
SELECT count(*) AS rows_after FROM ochl_day WHERE time IN '2026-09-14';
```
```text
rows_before
-----------
1
(1 row, 0.2 ms)
1 row(s) deleted
rows_after
----------
0
(1 row, 0.0 ms)
```
**Tip:** before a DELETE or UPDATE, run a `SELECT count(*)` with the **same WHERE**. Check the number. Then run the change.
To empty a whole table, `TRUNCATE TABLE ochl_day` is much faster than `DELETE FROM ochl_day`. The table, its columns and indexes stay; only the rows go.
## DROP PARTITION: delete a whole day, month or year fast
```sql
SELECT count(*) AS rows_2025 FROM ochl_day WHERE time IN '2025';
ALTER TABLE ochl_day DROP PARTITION '2025';
SELECT count(*) AS rows_2025 FROM ochl_day WHERE time IN '2025';
```
```text
rows_2025
---------
12
(1 row, 0.3 ms)
1 partition(s) of 'ochl_day' dropped
rows_2025
---------
0
(1 row, 0.0 ms)
```
The name of a partition depends on `PARTITION BY`: `'2026'` (YEAR), `'2026-09'` (MONTH), `'2026-09-10'` (DAY). You can drop many at once: `DROP PARTITION '2026-09-09', '2026-09-10'`. To drop everything older than some time, write `ALTER TABLE ochl_day DROP PARTITION OLDER THAN 1 YEAR`. A table with a TTL does this by itself (chapter 3). If the partition does not exist, KDB lists the ones that do:
```sql
ALTER TABLE ochl_day DROP PARTITION '2024';
```
```text
error: table 'ochl_day' has no partition '2024'; partitions: 2026
```
## Times and time zones
KDB stores every `TIMESTAMP` in **UTC**, in milliseconds. It always shows times in UTC, with a `Z` at the end. Your data may use any local time, such as New York (`-04:00` in summer) or Nepal (`+05:45`). The examples here use Nepal time (NPT, **UTC+05:45**, no daylight saving) for a sample market that opens at 11:00 local time.
| Market event | Nepal time | UTC |
|---|---|---|
| Market opens | 11:00 | 05:15 |
| Market closes | 15:00 | 09:15 |
| Midnight | 00:00 | 18:15 the day before |
You can write a time in several ways:
```sql
CREATE TABLE time_test (time TIMESTAMP, note STRING) TIMESERIES(time) PARTITION BY DAY;
INSERT INTO time_test VALUES
('2026-09-10T11:00:00+05:45', 'Nepal time, with +05:45'),
('2026-09-10T11:00:00+0545', 'Nepal time, without the colon'),
('2026-09-10 05:15:00', 'no zone = UTC'),
('2026-09-10T05:15:00Z', 'Z = UTC'),
('2026-09-10T05:15:00.250Z', 'with milliseconds'),
('2026-09-10', 'only a date = 00:00 UTC'),
(1789017300000, 'a number = milliseconds since 1970 (UTC)');
SELECT time, note FROM time_test;
```
```text
table 'time_test' created
7 row(s) inserted
time | note
-------------------------+-----------------------------------------
2026-09-10T00:00:00.000Z | only a date = 00:00 UTC
2026-09-10T05:15:00.000Z | Nepal time, with +05:45
2026-09-10T05:15:00.000Z | Nepal time, without the colon
2026-09-10T05:15:00.000Z | no zone = UTC
2026-09-10T05:15:00.000Z | Z = UTC
2026-09-10T05:15:00.000Z | a number = milliseconds since 1970 (UTC)
2026-09-10T05:15:00.250Z | with milliseconds
(7 rows, 0.1 ms)
```
The rules:
| You write | KDB reads it as |
|---|---|
| `'2026-09-10T11:00:00+05:45'` | 11:00 Nepal time = 05:15 UTC |
| `'2026-09-10 11:00:00'` (no zone) | 11:00 **UTC** (not Nepal time!) |
| `'2026-09-10T11:00:00Z'` | 11:00 UTC |
| `'2026-09-10'` | 00:00 UTC |
| `1789017300000` | milliseconds since 1970-01-01 UTC |
| More than 3 digits after the second (`.000000`) | only milliseconds are kept |
The format must be `YYYY-MM-DD`. `'10/09/2026'` or `'2026-9-10'` are errors (see chapter 12).
**The most common mistake:** writing Nepal time without `+05:45`. KDB then thinks it is UTC, and the row is 5 hours 45 minutes wrong. **Always add `+05:45` to Nepal times.**
The same rules work in WHERE. Trades between 11:00 and 11:05 Nepal time:
```sql
SELECT time, symbol, close
FROM ochl_minutes
WHERE symbol = 'CITY'
AND time >= '2026-09-10T11:00:00+05:45'
AND time < '2026-09-10T11:05:00+05:45';
```
```text
time | symbol | close
-------------------------+--------+------
2026-09-10T05:15:00.000Z | CITY | 504.4
2026-09-10T05:16:00.000Z | CITY | 504.6
2026-09-10T05:17:00.000Z | CITY | 504.2
2026-09-10T05:18:00.000Z | CITY | 504.4
2026-09-10T05:19:00.000Z | CITY | 504.5
(5 rows, 0.8 ms)
```
`time IN '2026-09-10'` means the **UTC** day. For the sample market this is fine, because the whole trading day (05:15 to 09:15 UTC) is inside the same UTC day.
## A script stops at the first error
When you send many statements at once (a file, or `a; b; c`), KDB runs them one by one. **Each statement is saved on its own.** At the first error, KDB stops.
```sql
INSERT INTO brokers VALUES (21, 'Test Broker A');
INSERT INTO brokers VALUES (21, 'Test Broker B');
INSERT INTO brokers VALUES (22, 'Test Broker C');
```
```text
1 row(s) inserted
error: a row with id='21' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it
```
```sql
SELECT id, name FROM brokers WHERE id IN (21, 22);
```
```text
id | name
---+--------------
21 | Test Broker A
(1 row, 0.2 ms)
```
- Statement 1 ran and is saved.
- Statement 2 failed.
- Statement 3 did **not** run.
If many changes must succeed together, put them between `BEGIN;` and `COMMIT;` (chapter 13 and chapter 15). Then an error undoes all of them. Or put them in **one** statement, or make the import safe to repeat with INSERT OR REPLACE.
## Fixing mistakes: a safe workflow
Bad data happens: a wrong file is imported, a price is 10 times too big, a day is loaded twice. QuestDB cannot delete single rows. KDB can. Use these steps.
Example: an import made every COAST minute price on 2026-09-10 ten times too big. (The UPDATE below only creates this "bad import" for the example.)
```sql
UPDATE ochl_minutes SET open = open * 10, high = high * 10, low = low * 10, close = close * 10
WHERE symbol = 'COAST' AND time IN '2026-09-10';
```
**Step 1. Look at the data. Count the bad rows.**
```sql
SELECT count(*) AS rows, min(low) AS min_low, max(high) AS max_high
FROM ochl_minutes
WHERE symbol = 'COAST' AND time IN '2026-09-10';
```
```text
rows | min_low | max_high
-----+---------+---------
236 | 3732 | 3820
(1 row, 0.3 ms)
```
236 rows, and the prices are near 3800 instead of 380. The WHERE is right.
**Step 2. Make a backup.** Then you can always go back.
```powershell
kdb backup market backups
```
```text
backup created and verified: backups/20260923-120922-256
9 files, 217.4 KB, log up to 52
```
(If `kdb serve` is running, use `BACKUP DATABASE` or the console's **Backup now** button instead. See chapter 9.)
**Step 3. Delete only the bad rows.** Use the same WHERE as in step 1.
```sql
DELETE FROM ochl_minutes WHERE symbol = 'COAST' AND time IN '2026-09-10';
```
```text
236 row(s) deleted
```
The number is the same as in step 1. Good.
**Step 4. Load the correct rows** with INSERT OR REPLACE. (Here only two minutes, to keep the example short.)
```sql
INSERT OR REPLACE INTO ochl_minutes VALUES
('2026-09-10T11:00:00+05:45', 'COAST', 380.1, 380.4, 379.8, 380.2, 900),
('2026-09-10T11:01:00+05:45', 'COAST', 380.2, 380.5, 380.0, 380.3, 650);
```
**Step 5. Check the result.**
```sql
SELECT time, symbol, open, high, low, close, volume
FROM ochl_minutes
WHERE symbol = 'COAST' AND time IN '2026-09-10';
```
```text
2 row(s) inserted
time | symbol | open | high | low | close | volume
-------------------------+--------+-------+-------+-------+-------+-------
2026-09-10T05:15:00.000Z | COAST | 380.1 | 380.4 | 379.8 | 380.2 | 900
2026-09-10T05:16:00.000Z | COAST | 380.2 | 380.5 | 380 | 380.3 | 650
(2 rows, 0.3 ms)
```
(The first line, `2 row(s) inserted`, is from step 4.)
If something went badly wrong (for example an UPDATE without WHERE), do not try to fix it row by row. **Restore to the time before the mistake** with `kdb restore --until` (chapter 9).
## Try it yourself
1. Insert a CITY live snapshot for `2026-09-10 14:30` Nepal time with ltp 520.5. Check that it is stored as `08:45` UTC.
2. Make every VOLT daily volume on 2026-09 ten percent bigger. First count the rows.
3. Delete all trade_log trades of broker 34 on 2026-09-09.
Answers
1. ```sql
INSERT INTO live_data (timestamp, symbol, ltp) VALUES ('2026-09-10T14:30:00+05:45', 'CITY', 520.5);
SELECT timestamp, ltp FROM live_data WHERE symbol = 'CITY' AND ltp = 520.5;
```
The time is `2026-09-10T08:45:00.000Z`.
2. ```sql
SELECT count(*) FROM ochl_day WHERE symbol = 'VOLT' AND time IN '2026-09';
UPDATE ochl_day SET volume = volume * 1.1 WHERE symbol = 'VOLT' AND time IN '2026-09';
```
3. ```sql
SELECT count(*) FROM trade_log WHERE date IN '2026-09-09' AND (buyer = 34 OR seller = 34);
DELETE FROM trade_log WHERE date IN '2026-09-09' AND (buyer = 34 OR seller = 34);
```
---
Next: [5. Queries](05-queries.md)
---
# 5. Queries
- Category: Tutorial
## What you will learn
- `SELECT` with `WHERE`, `ORDER BY`, `GROUP BY` and the special time filters.
- Candles with `SAMPLE BY` (1m, 15m, 1h in Nepal time, 1d, Sunday weeks, months), `LATEST ON` and joins.
- Indicators (`sma`, `ema`, `rsi`, `atr`, ...), window functions, views, and a cookbook of market-data queries.
The examples use the database `market` with the **fresh** sample data (load it again if you ran chapter 4). Each example is a file in `examples/05-queries/`.
## The order KDB works in
A query is written in this order:
```sql
SELECT ... FROM ... [JOIN ...] WHERE ... [LATEST ON ...] [SAMPLE BY ...] [GROUP BY ...] [HAVING ...]
[UNION ALL SELECT ...] [ORDER BY ...] [LIMIT n [OFFSET m]]
```
KDB runs it in this order:
| Step | Part | What happens |
|---|---|---|
| 1 | `FROM`, `JOIN` | Read the tables |
| 2 | `WHERE` | Keep only matching rows. A time condition skips whole partitions. |
| 3 | `LATEST ON` | Keep the newest row per group |
| 4 | `SAMPLE BY` / `GROUP BY` | Make groups, compute `sum`, `max`, ... |
| 5 | `HAVING` | Keep only matching groups |
| 6 | window functions | `sma`, `lag`, `row_number`, ... |
| 7 | `SELECT` list, `DISTINCT` | Compute the output columns |
| 8 | `UNION ALL` | Add rows of the next query |
| 9 | `ORDER BY`, `LIMIT`, `OFFSET` | Sort and cut |
## SELECT basics
```sql
SELECT * FROM brokers;
SELECT symbol, close,
round(high - low, 2) AS day_range,
round((close - open) / open * 100, 2) AS change_pct
FROM ochl_day
WHERE time IN '2026-09-10';
```
```text
id | name
---+--------------------
1 | Keystone Securities
34 | Vision Securities
45 | Imperial Securities
49 | Online Securities
58 | Northgate Securities
(5 rows, 0.3 ms)
symbol | close | day_range | change_pct
-------+-------+-----------+-----------
CITY | 504.9 | 6.3 | 0.06
COAST | 376.1 | 8.8 | -1.05
VOLT | 191.2 | 7.9 | 3.02
(3 rows, 0.2 ms)
```
- `AS name` gives a column a name. `AS` is optional: `close c` also works. So a **missing comma** (`symbol close`) is not an error: it names the column `close`.
- `/` always gives a decimal result: `7 / 2` is `3.5`.
- Without `round()`, `high - low` can show `6.30000000000001`. That is normal for DOUBLE.
## WHERE
```sql
SELECT time, symbol, close FROM ochl_day
WHERE symbol IN ('CITY', 'VOLT') AND close > 190 AND time IN '2026-09';
SELECT symbol, name FROM securities WHERE name LIKE '%Bank%';
SELECT time, symbol, close FROM ochl_day
WHERE symbol = 'COAST' AND time BETWEEN '2026-09-07' AND '2026-09-09';
```
```text
time | symbol | close
-------------------------+--------+------
2026-09-01T00:00:00.000Z | CITY | 502.4
2026-09-02T00:00:00.000Z | CITY | 496.4
2026-09-03T00:00:00.000Z | CITY | 497.9
2026-09-06T00:00:00.000Z | CITY | 499
2026-09-07T00:00:00.000Z | CITY | 498.7
2026-09-08T00:00:00.000Z | CITY | 497.5
2026-09-09T00:00:00.000Z | CITY | 504.6
2026-09-10T00:00:00.000Z | CITY | 504.9
2026-09-10T00:00:00.000Z | VOLT | 191.2
(9 rows, 0.2 ms)
symbol | name
-------+----------------------
CITY | City Bank Limited
COAST | Coastal Bank Limited
(2 rows, 0.1 ms)
time | symbol | close
-------------------------+--------+------
2026-09-07T00:00:00.000Z | COAST | 379.2
2026-09-08T00:00:00.000Z | COAST | 377.2
2026-09-09T00:00:00.000Z | COAST | 380.1
(3 rows, 0.0 ms)
```
| Condition | Meaning |
|---|---|
| `=`, `!=` or `<>`, `<`, `<=`, `>`, `>=` | compare |
| `a AND b`, `a OR b`, `NOT a` | combine. Use `( )` when you mix AND and OR. |
| `x IN (1, 2, 3)` / `x NOT IN (...)` | one of a list |
| `x BETWEEN a AND b` | `a <= x <= b` (both ends included) |
| `x LIKE 'CI%'` | text pattern: `%` = any text, `_` = one character |
| `x IS NULL` / `x IS NOT NULL` | no value / has a value |
| `time IN '2026'` | the whole year 2026 (UTC) |
| `time IN '2026-09'` | the whole month (UTC) |
| `time IN '2026-09-10'` | the whole day (UTC) |
| `time IN '2026-09-10T11'` | that hour (UTC) |
- A text like `'2026-09-07'` compared with a time column is read as a time.
- `BETWEEN '2026-09-07' AND '2026-09-09'` ends at `2026-09-09T00:00:00`. For whole days, use `time >= '2026-09-07' AND time < '2026-09-10'`.
- For Nepal times, add `+05:45` (chapter 4).
- `NULL` never matches `=`. Use `IS NULL`.
## ORDER BY, LIMIT, OFFSET
```sql
SELECT time, symbol, volume FROM ochl_day ORDER BY volume DESC LIMIT 3;
SELECT time, symbol, volume FROM ochl_day ORDER BY volume DESC LIMIT 3 OFFSET 3;
```
```text
time | symbol | volume
-------------------------+--------+-------
2026-09-09T00:00:00.000Z | COAST | 186330
2026-09-10T00:00:00.000Z | COAST | 186200
2026-09-10T00:00:00.000Z | CITY | 183040
(3 rows, 0.3 ms)
time | symbol | volume
-------------------------+--------+-------
2026-09-09T00:00:00.000Z | CITY | 182390
2026-08-31T00:00:00.000Z | CITY | 159240
2026-09-03T00:00:00.000Z | VOLT | 145930
(3 rows, 0.1 ms)
```
- `ASC` (small first) is the default. `DESC` = big first.
- `ORDER BY 2` sorts by the second output column.
- `NULLS FIRST` / `NULLS LAST` choose where NULL goes.
- Without ORDER BY, time-series rows come sorted by partition, then by `SORT BY`. Do not depend on it: add ORDER BY when order matters.
## GROUP BY and aggregates
```sql
SELECT symbol,
count(*) AS days,
min(low) AS lowest,
max(high) AS highest,
round(avg(close), 2) AS avg_close,
sum(volume) AS total_volume
FROM ochl_day
WHERE time IN '2026-09'
GROUP BY symbol
ORDER BY total_volume DESC;
```
```text
symbol | days | lowest | highest | avg_close | total_volume
-------+------+--------+---------+-----------+-------------
COAST | 8 | 364.1 | 398.8 | 374.73 | 910270
CITY | 8 | 494.1 | 512.4 | 500.18 | 876210
VOLT | 8 | 167.3 | 192.1 | 179.3 | 713310
(3 rows, 0.3 ms)
```
Aggregates: `count(*)`, `count(x)`, `count(DISTINCT x)`, `sum`, `avg`, `min`, `max`, `first`, `last`, `median`, `percentile(x, 0.9)`, `stddev`, `variance`, `vwap(price, volume)` and more (chapter 11).
`first(x)` and `last(x)` use the **time order** of the rows. `first(open)` is the open of the earliest row.
A `GROUP BY` with very many groups still works. When the groups need more than half of the memory for results, KDB writes some of them to temporary files and adds them up at the end. The result is the same, only slower. `kdb serve` stops a query with more than 5,000,000 groups (`--max-groups`, chapter 21).
### The implicit GROUP BY rule
Like QuestDB, KDB does not need GROUP BY. **Every column that is not inside an aggregate becomes a group key.**
```sql
-- No GROUP BY: symbol is not inside an aggregate, so KDB groups by symbol.
SELECT symbol, max(high) AS highest FROM ochl_day WHERE time IN '2026-09';
```
```text
symbol | highest
-------+--------
CITY | 512.4
COAST | 398.8
VOLT | 192.1
(3 rows, 0.3 ms)
```
In normal SQL this would be an error. Writing `GROUP BY symbol` gives the same result and is clearer for readers.
### HAVING
`HAVING` filters groups (WHERE filters rows).
```sql
SELECT symbol, sum(quantity) AS shares, count(*) AS trades
FROM trade_log
WHERE date IN '2026-09-10'
GROUP BY symbol
HAVING sum(quantity) >= 800
ORDER BY shares DESC;
```
```text
symbol | shares | trades
-------+--------+-------
COAST | 2850 | 9
CITY | 910 | 7
(2 rows, 0.4 ms)
```
## SAMPLE BY: candles
`SAMPLE BY interval` puts rows into time buckets. It works on time-series tables. The time column in the output is the **start** of each bucket. Other plain columns (like `symbol`) become group keys, as above.
Intervals: `1s`, `1m` (minute), `5m`, `15m`, `1h`, `1d`, `1w` (week), `1M` (month, capital M), `1y`.
### 15-minute candles
```sql
SELECT time,
first(open) AS open,
max(high) AS high,
min(low) AS low,
last(close) AS close,
sum(volume) AS volume
FROM ochl_minutes
WHERE symbol = 'CITY' AND time IN '2026-09-10'
SAMPLE BY 15m
LIMIT 4;
```
```text
time | open | high | low | close | volume
-------------------------+-------+-------+-------+-------+-------
2026-09-10T05:15:00.000Z | 504.6 | 505.3 | 503.9 | 504.4 | 13210
2026-09-10T05:30:00.000Z | 504.4 | 506.8 | 503.8 | 506.5 | 11690
2026-09-10T05:45:00.000Z | 506.5 | 507 | 505.3 | 506.4 | 11270
2026-09-10T06:00:00.000Z | 506.4 | 506.6 | 504.9 | 505.3 | 10850
(4 rows, 1.0 ms)
```
05:15 UTC is 11:00 Nepal time. For 1m, 5m and 15m, UTC buckets and Nepal buckets are the same.
### 1-hour candles: use TIME ZONE
Without a time zone, hours start at the full **UTC** hour. The sample market opens at 05:15 UTC, so you get a small 45-minute candle, three full ones, and a 15-minute one:
```sql
SELECT time, first(open) AS open, last(close) AS close, count(*) AS minutes
FROM ochl_minutes
WHERE symbol = 'CITY' AND time IN '2026-09-10'
SAMPLE BY 1h;
```
```text
time | open | close | minutes
-------------------------+-------+-------+--------
2026-09-10T05:00:00.000Z | 504.6 | 506.4 | 45
2026-09-10T06:00:00.000Z | 506.4 | 501.2 | 60
2026-09-10T07:00:00.000Z | 501.2 | 502.6 | 60
2026-09-10T08:00:00.000Z | 502.6 | 505 | 60
2026-09-10T09:00:00.000Z | 505 | 504.9 | 15
(5 rows, 0.8 ms)
```
With `TIME ZONE 'Asia/Kathmandu'`, buckets follow the Nepal clock: four clean candles, 11:00, 12:00, 13:00 and 14:00 NPT.
```sql
SELECT time,
first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close,
sum(volume) AS volume, count(*) AS minutes
FROM ochl_minutes
WHERE symbol = 'CITY' AND time IN '2026-09-10'
SAMPLE BY 1h TIME ZONE 'Asia/Kathmandu';
```
```text
time | open | high | low | close | volume | minutes
-------------------------+-------+-------+-------+-------+--------+--------
2026-09-10T05:15:00.000Z | 504.6 | 507 | 503.8 | 505.3 | 47020 | 60
2026-09-10T06:15:00.000Z | 505.3 | 506.6 | 500.7 | 502.7 | 41230 | 60
2026-09-10T07:15:00.000Z | 502.7 | 504.1 | 501.5 | 502.6 | 46180 | 60
2026-09-10T08:15:00.000Z | 502.6 | 507 | 502.2 | 504.9 | 48610 | 60
(4 rows, 0.7 ms)
```
The bucket times are still shown in UTC (`05:15Z` = 11:00 NPT).
Options of SAMPLE BY (any order, after the interval):
| Option | Meaning |
|---|---|
| `TIME ZONE 'Asia/Kathmandu'` | Buckets follow that clock. Also `'+05:45'`, `'UTC'`, `'Asia/Kolkata'` and a few more fixed zones. Zones with daylight saving (like `'Europe/London'`) are refused. |
| `WITH OFFSET '+00:15'` | Moves bucket starts by that amount. The sign (`+` or `-`) is needed. |
| `ALIGN TO CALENDAR` | Buckets on the clock (the default). |
| `ALIGN TO FIRST OBSERVATION` | Buckets start at the first row's time. Not with months or years. |
| `WEEK START SUNDAY` / `MONDAY` | For `1w`: which day starts the week. Default: Monday. |
| `FILL(NONE / NULL / PREV / LINEAR / value)` | What to do with empty buckets (see below). |
### Daily candles from minute candles
```sql
-- Daily candles made from minute candles.
SELECT time, symbol,
first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close,
sum(volume) AS volume
FROM ochl_minutes
SAMPLE BY 1d
ORDER BY symbol, time;
```
```text
time | symbol | open | high | low | close | volume
-------------------------+--------+-------+-------+-------+-------+-------
2026-09-09T00:00:00.000Z | CITY | 512 | 512.4 | 504 | 504.6 | 182390
2026-09-10T00:00:00.000Z | CITY | 504.6 | 507 | 500.7 | 504.9 | 183040
2026-09-09T00:00:00.000Z | COAST | 398 | 398.8 | 379.2 | 380.1 | 186330
2026-09-10T00:00:00.000Z | COAST | 380.1 | 382 | 373.2 | 376.1 | 186200
(4 rows, 1.0 ms)
```
Without TIME ZONE the day bucket is stamped `00:00Z`, like the rows in `ochl_day`. With `TIME ZONE 'Asia/Kathmandu'` the bucket starts at Nepal midnight, which is shown as `18:15Z` of the day before. The trading hours are inside the same day either way.
### Weekly candles: Sunday to Thursday
The sample market has a Sunday-to-Thursday trading week (as on some exchanges). Use `WEEK START SUNDAY`:
```sql
SELECT time, symbol,
first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close,
sum(volume) AS volume
FROM ochl_day
WHERE symbol = 'CITY' AND time IN '2026'
SAMPLE BY 1w WEEK START SUNDAY;
```
```text
time | symbol | open | high | low | close | volume
-------------------------+--------+-------+-------+-------+-------+-------
2026-08-23T00:00:00.000Z | CITY | 505 | 517 | 502 | 505.9 | 601770
2026-08-30T00:00:00.000Z | CITY | 507.3 | 511.2 | 494.1 | 497.9 | 538130
2026-09-06T00:00:00.000Z | CITY | 497.4 | 512.4 | 495 | 504.9 | 591860
(3 rows, 0.3 ms)
```
2026-08-23, 08-30 and 09-06 are Sundays.
### Monthly candles
`1M` is a calendar month (capital `M`; small `m` is minutes).
```sql
SELECT time, symbol, first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close
FROM ochl_day
WHERE symbol = 'VOLT'
SAMPLE BY 1M;
```
```text
time | symbol | open | high | low | close
-------------------------+--------+-------+-------+-------+------
2025-12-01T00:00:00.000Z | VOLT | 187.7 | 199.7 | 187.5 | 194.3
2026-08-01T00:00:00.000Z | VOLT | 182.4 | 183.6 | 170.6 | 172.6
2026-09-01T00:00:00.000Z | VOLT | 171.3 | 192.1 | 167.3 | 191.2
(3 rows, 0.6 ms)
```
Months with no rows (January to July 2026 here) are not shown. Use FILL if you need them.
### FILL: empty buckets
COAST had no trades from 12:31 to 12:34 Nepal time. Without FILL, those minutes are missing:
```sql
-- COAST had no trades from 12:31 to 12:34 Nepal time.
SELECT time, last(close) AS close, sum(volume) AS volume
FROM ochl_minutes
WHERE symbol = 'COAST'
AND time >= '2026-09-10T12:30:00+05:45' AND time < '2026-09-10T12:36:00+05:45'
SAMPLE BY 1m;
SELECT time, last(close) AS close, sum(volume) AS volume
FROM ochl_minutes
WHERE symbol = 'COAST'
AND time >= '2026-09-10T12:30:00+05:45' AND time < '2026-09-10T12:36:00+05:45'
SAMPLE BY 1m FILL(PREV);
```
```text
time | close | volume
-------------------------+-------+-------
2026-09-10T06:45:00.000Z | 380.2 | 620
2026-09-10T06:50:00.000Z | 379.8 | 800
(2 rows, 0.7 ms)
time | close | volume
-------------------------+-------+-------
2026-09-10T06:45:00.000Z | 380.2 | 620
2026-09-10T06:46:00.000Z | 380.2 | 620
2026-09-10T06:47:00.000Z | 380.2 | 620
2026-09-10T06:48:00.000Z | 380.2 | 620
2026-09-10T06:49:00.000Z | 380.2 | 620
2026-09-10T06:50:00.000Z | 379.8 | 800
(6 rows, 0.3 ms)
```
| FILL | Empty bucket gets | Example value for close |
|---|---|---|
| (none) or `FILL(NONE)` | no row | - |
| `FILL(NULL)` | NULL | `NULL` |
| `FILL(PREV)` | the values of the bucket before | `380.2` |
| `FILL(LINEAR)` | a value on the straight line between the neighbors | `380.12`, `380.04`, ... |
| `FILL(0)` | that constant | `0` |
FILL uses one rule for **every** aggregate column. `FILL(PREV)` also copies the volume (620 above), which is wrong for volume. If you need "close = previous, volume = 0", get the rows with `FILL(NULL)` and handle them in your program, or run two queries. The outputs of FILL(NULL), LINEAR and 0 are in `examples/05-queries/14-fill-other.out`.
FILL only fills between the first and the last bucket that have data.
## LATEST ON: the newest row per symbol
```sql
SELECT symbol, ltp, pct_change, timestamp
FROM live_data
LATEST ON timestamp PARTITION BY symbol
ORDER BY pct_change DESC;
```
```text
symbol | ltp | pct_change | timestamp
-------+-------+------------+-------------------------
CITY | 527.1 | 2.71 | 2026-09-10T09:14:00.000Z
TELCO | 897.7 | 1.9 | 2026-09-10T09:14:00.000Z
COAST | 401.9 | 0.7 | 2026-09-10T09:14:00.000Z
VOLT | 184 | -0.86 | 2026-09-10T09:14:00.000Z
(4 rows, 0.2 ms)
```
- `LATEST ON PARTITION BY ` keeps the row with the biggest time for each group.
- WHERE runs **before** LATEST ON. `WHERE timestamp >= '2026-09-10'` gives "the newest row of today".
- LATEST ON works on one table (no joins).
## JOIN
```sql
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;
```
```text
transaction_id | symbol | buyer | seller | quantity | rate | amount
---------------+--------+---------------------+---------------------+----------+------+-------
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, 0.4 ms)
```
- `f`, `b` and `s` are short names (aliases) for the tables. The same table (`brokers`) can be joined twice with two aliases.
- When a column name is in two tables, write `table.column` (for example `f.symbol`).
| Join | Rows you get |
|---|---|
| `JOIN` (or `INNER JOIN`) | only rows that match on both sides |
| `LEFT JOIN` (or `LEFT OUTER JOIN`) | all rows of the left table; NULL when the right side has no match |
| `CROSS JOIN` | every left row with every right row (careful: it can be huge) |
| `ASOF JOIN` | for each left row, the last right row at or before its time |
`LEFT JOIN`: every security, also the ones without trades:
```sql
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;
```
```text
symbol | trades
-------+-------
CITY | 11
COAST | 11
TELCO | 6
VOLT | 4
ACME | 0
(5 rows, 0.6 ms)
```
### ASOF JOIN
`ASOF JOIN` matches by time. For each row of the first table, it takes the **last row of the second table at or before that time** (with the same `ON` values). Both tables must be time-series tables.
```sql
-- For each live snapshot: the last minute candle at or before that time.
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;
```
```text
timestamp | symbol | ltp | candle_time | candle_close
-------------------------+--------+-------+--------------------------+-------------
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, 1.0 ms)
```
(The sample live prices and minute prices are made up separately, so they do not agree.)
## Subqueries
```sql
-- IN (SELECT ...)
SELECT symbol, name FROM securities
WHERE symbol IN (SELECT symbol FROM trade_log WHERE quantity >= 1000);
-- a value from a subquery
SELECT symbol, close, (SELECT max(close) FROM ochl_day WHERE time IN '2026-09-10') AS best_close
FROM ochl_day WHERE time IN '2026-09-10';
-- FROM (SELECT ...) needs a name (here: t)
SELECT t.symbol, t.trades
FROM (SELECT symbol, count(*) AS trades FROM trade_log GROUP BY symbol) t
WHERE t.trades > 5;
```
```text
symbol | name
-------+----------------------------------
COAST | Coastal Bank Limited
TELCO | Northwind Telecom Limited
(2 rows, 0.3 ms)
symbol | close | best_close
-------+-------+-----------
CITY | 504.9 | 504.9
COAST | 376.1 | 504.9
VOLT | 191.2 | 504.9
(3 rows, 0.1 ms)
symbol | trades
-------+-------
CITY | 11
COAST | 11
TELCO | 6
(3 rows, 0.1 ms)
```
Also available: `EXISTS (SELECT ...)` and `NOT IN (SELECT ...)`.
A subquery **may use columns of the outer query** ("correlated"), for example
`SELECT s.symbol, (SELECT max(close) FROM ochl_day d WHERE d.symbol = s.symbol) FROM securities s`.
KDB does not run it once per row: it reads the inner table once and looks every outer row up in that
result. See chapters 11 and 23.
## UNION ALL
```sql
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;
```
```text
symbol | side | shares
-------+------+-------
CITY | sell | 20
COAST | buy | 760
COAST | sell | 1200
TELCO | buy | 1000
VOLT | buy | 200
VOLT | sell | 50
(6 rows, 0.4 ms)
```
- Every part must have the same number of columns.
- `ORDER BY` and `LIMIT` at the end work on all rows together. There they may only use output column names or positions.
- Only `UNION ALL` exists (it keeps duplicates). There is no `UNION`, `INTERSECT` or `EXCEPT`.
## CASE and CAST
```sql
SELECT symbol, pct_change,
CASE WHEN pct_change > 0 THEN 'up'
WHEN pct_change < 0 THEN 'down'
ELSE 'flat' END AS direction
FROM live_data
LATEST ON timestamp PARTITION BY symbol;
SELECT CAST('512.5' AS DOUBLE) + 1 AS a,
CAST(1500.256 AS DECIMAL(3)) AS b,
CAST('2026-09-10T11:00:00+05:45' AS TIMESTAMP) AS c;
```
```text
symbol | pct_change | direction
-------+------------+----------
CITY | 2.71 | up
COAST | 0.7 | up
TELCO | 1.9 | up
VOLT | -0.86 | down
(4 rows, 0.3 ms)
a | b | c
------+----------+-------------------------
513.5 | 1500.256 | 2026-09-10T05:15:00.000Z
(1 row, 0.1 ms)
```
- `CASE x WHEN 1 THEN 'a' WHEN 2 THEN 'b' END` (compare one value) also works.
- `x::INT` is the short form of `CAST(x AS INT)`.
- `SELECT` without `FROM` works: `SELECT 1 + 1`.
## DISTINCT
```sql
SELECT DISTINCT sector FROM securities ORDER BY sector;
SELECT count(DISTINCT symbol) AS symbols_traded FROM trade_log;
```
```text
sector
----------
Banking
Energy
Investment
Telecom
(4 rows, 0.2 ms)
symbols_traded
--------------
4
(1 row, 0.2 ms)
```
## Indicators and window functions
These functions look at **a series of rows**, not one row.
### Indicators
```sql
SELECT time, close,
round(sma(close, 3), 2) AS sma3,
round(ema(close, 3), 2) AS ema3,
round(rsi(close, 3), 1) AS rsi3,
round(atr(high, low, close, 3), 2) AS atr3
FROM ochl_day
WHERE symbol = 'CITY' AND time IN '2026-09';
```
```text
time | close | sma3 | ema3 | rsi3 | atr3
-------------------------+-------+--------+--------+------+-----
2026-09-01T00:00:00.000Z | 502.4 | NULL | NULL | NULL | NULL
2026-09-02T00:00:00.000Z | 496.4 | NULL | NULL | NULL | NULL
2026-09-03T00:00:00.000Z | 497.9 | 498.9 | 498.9 | NULL | NULL
2026-09-06T00:00:00.000Z | 499 | 497.77 | 498.95 | 30.2 | 6.07
2026-09-07T00:00:00.000Z | 498.7 | 498.53 | 498.82 | 28.7 | 6.04
2026-09-08T00:00:00.000Z | 497.5 | 498.4 | 498.16 | 22.1 | 5.23
2026-09-09T00:00:00.000Z | 504.6 | 500.27 | 501.38 | 74.4 | 8.45
2026-09-10T00:00:00.000Z | 504.9 | 502.33 | 503.14 | 75.4 | 7.74
(8 rows, 0.4 ms)
```
| Function | Meaning | First value |
|---|---|---|
| `sma(x, n)` | simple moving average of the last n rows | row n |
| `ema(x, n)` | exponential moving average (starts with the SMA, like TA-Lib) | row n |
| `rsi(x, n)` | Wilder's RSI, 0 to 100 (like TA-Lib) | row n+1 |
| `atr(high, low, close, n)` | Wilder's average true range | row n+1 |
| `lag(x)` / `lag(x, k)` | the value k rows before (default 1) | row k+1 |
| `lead(x)` / `lead(x, k)` | the value k rows after | - |
| `pct_change(x)` | percent change from the row before | row 2 |
| `cumsum(x)` | running total | row 1 |
| `row_number()` | 1, 2, 3, ... | row 1 |
| `rank()` / `dense_rank()` | rank; equal values get the same rank | row 1 |
Before there are enough rows, the result is NULL.
### The series order
Series functions run in **time order** of the table's time column. Use `OVER (...)` to choose the order or to split the series:
```sql
SELECT time, close,
lag(close) AS prev_close,
lead(close) AS next_close,
round(pct_change(close), 2) AS chg_pct,
cumsum(volume) AS running_volume,
row_number() OVER (ORDER BY volume DESC) AS volume_rank
FROM ochl_day
WHERE symbol = 'VOLT' AND time >= '2026-09-06';
```
```text
time | close | prev_close | next_close | chg_pct | running_volume | volume_rank
-------------------------+-------+------------+------------+---------+----------------+------------
2026-09-06T00:00:00.000Z | 180.1 | NULL | 176.9 | NULL | 78550 | 4
2026-09-07T00:00:00.000Z | 176.9 | 180.1 | 182.4 | -1.78 | 214110 | 2
2026-09-08T00:00:00.000Z | 182.4 | 176.9 | 187.3 | 3.11 | 350880 | 1
2026-09-09T00:00:00.000Z | 187.3 | 182.4 | 191.2 | 2.69 | 445290 | 3
2026-09-10T00:00:00.000Z | 191.2 | 187.3 | NULL | 2.08 | 486760 | 5
(5 rows, 0.3 ms)
```
**Many symbols:** without `PARTITION BY`, all rows are one series, and CITY's SMA would use COAST's prices. Use `OVER (PARTITION BY symbol ORDER BY time)`:
```sql
SELECT time, symbol, close,
round(sma(close, 3) OVER (PARTITION BY symbol ORDER BY time), 2) AS sma3
FROM ochl_day
WHERE time >= '2026-09-06'
ORDER BY symbol, time;
```
```text
time | symbol | close | sma3
-------------------------+--------+-------+-------
2026-09-06T00:00:00.000Z | CITY | 499 | NULL
2026-09-07T00:00:00.000Z | CITY | 498.7 | NULL
2026-09-08T00:00:00.000Z | CITY | 497.5 | 498.4
2026-09-09T00:00:00.000Z | CITY | 504.6 | 500.27
2026-09-10T00:00:00.000Z | CITY | 504.9 | 502.33
2026-09-06T00:00:00.000Z | COAST | 375.4 | NULL
2026-09-07T00:00:00.000Z | COAST | 379.2 | NULL
2026-09-08T00:00:00.000Z | COAST | 377.2 | 377.27
2026-09-09T00:00:00.000Z | COAST | 380.1 | 378.83
2026-09-10T00:00:00.000Z | COAST | 376.1 | 377.8
2026-09-06T00:00:00.000Z | VOLT | 180.1 | NULL
2026-09-07T00:00:00.000Z | VOLT | 176.9 | NULL
2026-09-08T00:00:00.000Z | VOLT | 182.4 | 179.8
2026-09-09T00:00:00.000Z | VOLT | 187.3 | 182.2
2026-09-10T00:00:00.000Z | VOLT | 191.2 | 186.97
(15 rows, 0.3 ms)
```
### Aggregates over a window: ROWS n PRECEDING
Any aggregate can run over a window of rows: `sum(x) OVER (PARTITION BY ... ORDER BY ... ROWS n PRECEDING)` uses the current row and up to n rows before it. `ROWS BETWEEN 2 PRECEDING AND CURRENT ROW` is the same as `ROWS 2 PRECEDING`.
```sql
SELECT time, symbol, volume,
sum(volume) OVER (PARTITION BY symbol ORDER BY time ROWS 2 PRECEDING) AS vol_3d,
round(avg(close) OVER (PARTITION BY symbol ORDER BY time ROWS 2 PRECEDING), 2) AS avg_close_3d,
rank() OVER (PARTITION BY time ORDER BY volume DESC) AS rank_that_day
FROM ochl_day
WHERE time >= '2026-09-08'
ORDER BY symbol, time;
```
```text
time | symbol | volume | vol_3d | avg_close_3d | rank_that_day
-------------------------+--------+--------+--------+--------------+--------------
2026-09-08T00:00:00.000Z | CITY | 34400 | 34400 | 497.5 | 3
2026-09-09T00:00:00.000Z | CITY | 182390 | 216790 | 501.05 | 2
2026-09-10T00:00:00.000Z | CITY | 183040 | 399830 | 502.33 | 2
2026-09-08T00:00:00.000Z | COAST | 60980 | 60980 | 377.2 | 2
2026-09-09T00:00:00.000Z | COAST | 186330 | 247310 | 378.65 | 1
2026-09-10T00:00:00.000Z | COAST | 186200 | 433510 | 377.8 | 1
2026-09-08T00:00:00.000Z | VOLT | 136770 | 136770 | 182.4 | 1
2026-09-09T00:00:00.000Z | VOLT | 94410 | 231180 | 184.85 | 3
2026-09-10T00:00:00.000Z | VOLT | 41470 | 272650 | 186.97 | 3
(9 rows, 0.4 ms)
```
| OVER (...) | Window for each row |
|---|---|
| `OVER ()` | all rows (of the result) |
| `OVER (PARTITION BY symbol)` | all rows of that symbol |
| `OVER (PARTITION BY symbol ORDER BY time)` | from the first row of that symbol to this row (running) |
| `OVER (PARTITION BY symbol ORDER BY time ROWS 19 PRECEDING)` | this row and the 19 before it |
### Rule 1: WHERE runs first
Indicators only see the rows that WHERE keeps. A 3-day SMA on "the last 3 days" has only 3 rows to work with:
```sql
-- WHERE runs first. Here sma3 has only 3 days to use, so the first 2 rows are NULL:
SELECT time, close, round(sma(close, 3), 2) AS sma3
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-09-08';
-- Better: compute on more history inside, filter outside.
SELECT time, close, sma3
FROM (SELECT time, close, round(sma(close, 3), 2) AS sma3 FROM ochl_day WHERE symbol = 'CITY') t
WHERE time >= '2026-09-08';
```
```text
time | close | sma3
-------------------------+-------+-------
2026-09-08T00:00:00.000Z | 497.5 | NULL
2026-09-09T00:00:00.000Z | 504.6 | NULL
2026-09-10T00:00:00.000Z | 504.9 | 502.33
(3 rows, 0.3 ms)
time | close | sma3
-------------------------+-------+-------
2026-09-08T00:00:00.000Z | 497.5 | 498.4
2026-09-09T00:00:00.000Z | 504.6 | 500.27
2026-09-10T00:00:00.000Z | 504.9 | 502.33
(3 rows, 0.1 ms)
```
### Rule 2: with SAMPLE BY or GROUP BY, the window sees the groups
A window function in a query with `SAMPLE BY`, `GROUP BY` or aggregates runs **after** the groups are made. It reads one value per group, not the rows of the table. So it may only use the keys of the groups and aggregates. A plain column is refused:
```sql
SELECT time, last(close) AS close, sma(close, 2) AS sma2
FROM ochl_day
WHERE symbol = 'CITY'
SAMPLE BY 1w WEEK START SUNDAY;
```
```text
error: line 1, column 40: column 'close' is used by a window function in a query with aggregates, so it must be a key of the groups: add it to the SELECT list or to GROUP BY
```
`close` has many values in one week. Say which one you mean: the week's last close is `last(close)`. Put the aggregate inside the window function:
```sql
SELECT time, last(close) AS close, sma(last(close), 2) AS sma2
FROM ochl_day
WHERE symbol = 'CITY' AND time IN '2026'
SAMPLE BY 1w WEEK START SUNDAY;
```
```text
time | close | sma2
-------------------------+-------+------
2026-08-23T00:00:00.000Z | 505.9 | NULL
2026-08-30T00:00:00.000Z | 497.9 | 501.9
2026-09-06T00:00:00.000Z | 504.9 | 501.4
(3 rows, 0.5 ms)
```
A subquery gives the same result: the candles inside, the indicator outside. Use this form when the indicator needs several columns of the candle, or when you build it in steps.
```sql
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;
```
```text
time | close | sma2
-------------------------+-------+------
2026-08-23T00:00:00.000Z | 505.9 | NULL
2026-08-30T00:00:00.000Z | 497.9 | 501.9
2026-09-06T00:00:00.000Z | 504.9 | 501.4
(3 rows, 0.4 ms)
```
The same works with `GROUP BY`, for example a rank of the groups: `SELECT symbol, sum(volume) AS volume, rank() OVER (ORDER BY sum(volume) DESC) AS place FROM ochl_day GROUP BY symbol`.
A series function cannot be inside another one (`lag(sma(close, 3))`). Use one subquery level per step (see the moving average crossover in the cookbook).
## Views
A view is a saved query with a name. You use it like a table.
```sql
CREATE VIEW candles_1w AS
SELECT time, symbol,
first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close,
sum(volume) AS volume
FROM ochl_day
SAMPLE BY 1w WEEK START SUNDAY;
SELECT * FROM candles_1w WHERE symbol = 'VOLT' AND time >= '2026-08-30';
```
```text
view 'candles_1w' created
time | symbol | open | high | low | close | volume
-------------------------+--------+-------+-------+-------+-------+-------
2026-08-30T00:00:00.000Z | VOLT | 172.5 | 177.3 | 167.3 | 176 | 368150
2026-09-06T00:00:00.000Z | VOLT | 174.2 | 192.1 | 172.4 | 191.2 | 486760
(2 rows, 0.4 ms)
```
```powershell
kdb sql market "SHOW VIEWS; DROP VIEW candles_1w; SHOW VIEWS" --format csv
```
```text
name,query
candles_1w,"SELECT time, symbol, first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close, sum(volume) AS volume FROM ochl_day SAMPLE BY 1w WEEK START SUNDAY"
name,query
```
- The view runs its query **every time** you use it.
- KDB moves a WHERE on the view into the view's query when it can, so it reads fewer rows. Here `symbol = 'VOLT'` is moved in; the time condition is applied to the weekly candles after they are made. `EXPLAIN` shows `push 1 condition(s) into view candles_1w`.
- `CREATE VIEW IF NOT EXISTS`, `DROP VIEW IF EXISTS` work.
- KDB checks the query when you create the view. A view with an error is refused.
A **cached view** runs its query once and keeps the rows. Reading it reads the kept rows, so a slow `GROUP BY` over a big table is paid once, not on every read. With `REFRESH ON COMMIT`, KDB makes the rows new at the first read after a change of a table the view reads:
```sql
CREATE CACHED VIEW trades_per_symbol AS
SELECT symbol, count(*) AS trades FROM trade_log
REFRESH ON COMMIT;
```
```text
view 'trades_per_symbol' created (4 row(s) cached)
```
- Without `REFRESH ON COMMIT`, the rows stay as they are until you run `REFRESH VIEW trades_per_symbol`.
- The kept rows live in memory. After a restart they are made again at the first read.
- A `WHERE` on a cached view filters the kept rows. It does not run the view's query again.
## EXPLAIN: what will KDB read?
`EXPLAIN` shows the plan, without running the query. Use CSV format so lines are not cut.
```powershell
kdb sql market "EXPLAIN SELECT time, close FROM ochl_day WHERE symbol = 'CITY' AND time IN '2026-09'" --format csv
```
```text
plan
"scan table ochl_day (1 of 2 partitions, about 57 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)
```
- `1 of 2 partitions`: the `2025` file is skipped because of the time condition.
- `read only 1 key range(s)`: rows are sorted by symbol and time, so KDB jumps straight to CITY's rows of September and reads nothing else. This works for `symbol = '...'`, `symbol IN (...)`, and a time range on top of them.
- `build 3 of 7 stored column(s)`: the table has 7 columns, but KDB makes only the 3 that the query needs (`symbol` for the filter, `time` and `close` for the result).
A second example with a join:
```sql
EXPLAIN SELECT b.name, sum(f.amount) AS turnover
FROM trade_log f JOIN brokers b ON f.buyer = b.id
WHERE f.date IN '2026-09-10'
GROUP BY b.name ORDER BY turnover DESC LIMIT 3;
```
```text
plan
"scan table trade_log (1 of 2 partitions, about 32 rows)"
"join (hash on 1 key(s)) table brokers (1 of 1 partitions, about 5 rows)"
filter while scanning: 1 condition(s)
"group by 1 key(s), 1 aggregate(s)"
sort by 1 key(s)
limit 3
```
Only the day 2026-09-10 is read. (For a `DATE` time column KDB also reads the partition of the next day, to be safe.)
**Speed tips:**
- Always filter time-series tables by time (`time IN '...'`, `>=`, `<`, `BETWEEN`) on the first table of the query.
- Filter by symbol with `symbol = '...'` or `symbol IN (...)` written plainly: `upper(symbol) = 'CITY'` or `concat(symbol, '') = ...` makes KDB read every row.
- Put the big table first in FROM, and join small tables to it.
- Select only the columns you need.
## Cookbook
Ready-made market-data queries. Each one is verified on the sample data.
### Weekly candles for all symbols
```sql
SELECT time, symbol,
first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close,
sum(volume) AS volume
FROM ochl_day
WHERE time IN '2026'
SAMPLE BY 1w WEEK START SUNDAY
ORDER BY symbol, time;
```
```text
time | symbol | open | high | low | close | volume
-------------------------+--------+-------+-------+-------+-------+-------
2026-08-23T00:00:00.000Z | CITY | 505 | 517 | 502 | 505.9 | 601770
2026-08-30T00:00:00.000Z | CITY | 507.3 | 511.2 | 494.1 | 497.9 | 538130
2026-09-06T00:00:00.000Z | CITY | 497.4 | 512.4 | 495 | 504.9 | 591860
2026-08-23T00:00:00.000Z | COAST | 392.1 | 393.3 | 366.2 | 367.1 | 421000
2026-08-30T00:00:00.000Z | COAST | 368 | 375.4 | 364.1 | 373.2 | 356390
2026-09-06T00:00:00.000Z | COAST | 374.6 | 398.8 | 372.5 | 376.1 | 690140
2026-08-23T00:00:00.000Z | VOLT | 182.4 | 183.6 | 172 | 173.3 | 535470
2026-08-30T00:00:00.000Z | VOLT | 172.5 | 177.3 | 167.3 | 176 | 368150
2026-09-06T00:00:00.000Z | VOLT | 174.2 | 192.1 | 172.4 | 191.2 | 486760
(9 rows, 0.4 ms)
```
### Top gainers and losers of a day (from daily candles)
```sql
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;
```
```text
symbol | prev_close | close | change_pct
-------+------------+-------+-----------
VOLT | 187.3 | 191.2 | 2.08
CITY | 504.6 | 504.9 | 0.06
COAST | 380.1 | 376.1 | -1.05
(3 rows, 0.4 ms)
```
For losers use `ORDER BY change_pct` (small first). Add `LIMIT 10` for a top 10. The inner query reads all days; for a big table add `WHERE time >= '2026-09-01'` inside (a few days before the day you want).
### Top gainers and losers right now (from live data)
```sql
-- Top gainers and losers right now, from the live snapshots.
SELECT symbol, ltp, pct_change
FROM live_data
LATEST ON timestamp PARTITION BY symbol
ORDER BY pct_change DESC
LIMIT 2;
SELECT symbol, ltp, pct_change
FROM live_data
LATEST ON timestamp PARTITION BY symbol
ORDER BY pct_change
LIMIT 2;
```
```text
symbol | ltp | pct_change
-------+-------+-----------
CITY | 527.1 | 2.71
TELCO | 897.7 | 1.9
(2 rows, 0.2 ms)
symbol | ltp | pct_change
-------+-------+-----------
VOLT | 184 | -0.86
COAST | 401.9 | 0.7
(2 rows, 0.0 ms)
```
### Broker buy and sell summary (trade_log)
```sql
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,
sum(CASE WHEN f.buyer = b.id THEN f.quantity ELSE 0 END)
- sum(CASE WHEN f.seller = b.id THEN f.quantity ELSE 0 END) AS net
FROM brokers b
CROSS JOIN trade_log f
WHERE f.date IN '2026-09-10'
GROUP BY b.id, b.name
ORDER BY net DESC;
```
```text
id | name | bought | sold | net
---+---------------------+--------+------+-----
49 | Online Securities | 1740 | 340 | 1400
45 | Imperial Securities | 1560 | 1520 | 40
34 | Vision Securities | 300 | 700 | -400
58 | Northgate Securities | 760 | 1270 | -510
1 | Keystone Securities | 570 | 1100 | -530
(5 rows, 1.0 ms)
```
The CROSS JOIN makes (brokers × trades) rows. That is fine for one day. For many days and all ~90 brokers, use the next query per broker, or the UNION ALL form shown earlier.
One broker, per symbol, with amounts:
```sql
-- One broker (58), buy and sell per symbol, with amounts.
SELECT symbol,
sum(CASE WHEN buyer = 58 THEN quantity ELSE 0 END) AS buy_qty,
sum(CASE WHEN seller = 58 THEN quantity ELSE 0 END) AS sell_qty,
round(sum(CASE WHEN buyer = 58 THEN amount ELSE 0 END), 2) AS buy_amount,
round(sum(CASE WHEN seller = 58 THEN amount ELSE 0 END), 2) AS sell_amount
FROM trade_log
WHERE date IN '2026-09-10' AND (buyer = 58 OR seller = 58)
GROUP BY symbol
ORDER BY symbol;
```
```text
symbol | buy_qty | sell_qty | buy_amount | sell_amount
-------+---------+----------+------------+------------
CITY | 0 | 20 | 0 | 10300
COAST | 560 | 1200 | 223390 | 482000
VOLT | 200 | 50 | 36800 | 9200
(3 rows, 0.4 ms)
```
### Volume-weighted average price (VWAP)
```sql
SELECT symbol,
round(vwap(rate, quantity), 2) AS vwap,
round(sum(amount) / sum(quantity), 2) AS vwap_by_hand,
sum(quantity) AS shares,
count(*) AS trades
FROM trade_log
WHERE date IN '2026-09-10'
GROUP BY symbol
ORDER BY symbol;
```
```text
symbol | vwap | vwap_by_hand | shares | trades
-------+--------+--------------+--------+-------
CITY | 516.3 | 516.3 | 910 | 7
COAST | 399.77 | 399.77 | 2850 | 9
TELCO | 880.86 | 880.86 | 770 | 4
VOLT | 184.88 | 184.88 | 400 | 4
(4 rows, 0.4 ms)
```
### 52-week high and low
```sql
SELECT symbol, max(high) AS high_52w, min(low) AS low_52w
FROM ochl_day
WHERE time > dateadd('d', -365, CAST('2026-09-10' AS TIMESTAMP))
GROUP BY symbol
ORDER BY symbol;
```
```text
symbol | high_52w | low_52w
-------+----------+--------
CITY | 546.2 | 494.1
COAST | 429.9 | 364.1
VOLT | 199.7 | 167.3
(3 rows, 0.4 ms)
```
For "until today", write `dateadd('d', -365, now())`.
### Moving average crossover
A BUY signal when the 3-day SMA goes above the 5-day SMA; SELL when it goes below.
```sql
SELECT time, close, fast, slow,
CASE WHEN fast > slow AND prev_fast <= prev_slow THEN 'BUY'
WHEN fast < slow AND prev_fast >= prev_slow THEN 'SELL'
ELSE '' END AS signal
FROM (
SELECT time, close, fast, slow, lag(fast) AS prev_fast, lag(slow) AS prev_slow
FROM (
SELECT time, close, round(sma(close, 3), 2) AS fast, round(sma(close, 5), 2) AS slow
FROM ochl_day
WHERE symbol = 'VOLT' AND time IN '2026'
) a
) b
WHERE time >= '2026-08-30';
```
```text
time | close | fast | slow | signal
-------------------------+-------+--------+--------+-------
2026-08-30T00:00:00.000Z | 173 | 173.5 | 175.16 |
2026-08-31T00:00:00.000Z | 172.6 | 172.97 | 173.98 |
2026-09-01T00:00:00.000Z | 169 | 171.53 | 172.42 |
2026-09-02T00:00:00.000Z | 171.5 | 171.03 | 171.88 |
2026-09-03T00:00:00.000Z | 176 | 172.17 | 172.42 |
2026-09-06T00:00:00.000Z | 180.1 | 175.87 | 173.84 | BUY
2026-09-07T00:00:00.000Z | 176.9 | 177.67 | 174.7 |
2026-09-08T00:00:00.000Z | 182.4 | 179.8 | 177.38 |
2026-09-09T00:00:00.000Z | 187.3 | 182.2 | 180.54 |
2026-09-10T00:00:00.000Z | 191.2 | 186.97 | 183.58 |
(10 rows, 0.4 ms)
```
For real use, choose periods like 20 and 50, and read enough history in the innermost query.
### Turnover by sector
```sql
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;
```
```text
sector | trades | shares | turnover
--------+--------+--------+---------
Banking | 16 | 3760 | 1609170
Telecom | 4 | 770 | 678260
Energy | 4 | 400 | 73950
(3 rows, 0.4 ms)
```
### Volume per Nepal clock hour
`hour()` gives the UTC hour. Add 5 hours 45 minutes (20700000 milliseconds) to get the Nepal hour:
```sql
-- hour() works in UTC. Add 5h45m (20700000 ms) to get the Nepal clock hour.
SELECT hour(time + 20700000) AS npt_hour, sum(volume) AS volume
FROM ochl_minutes
WHERE symbol = 'CITY' AND time IN '2026-09-10'
GROUP BY hour(time + 20700000)
ORDER BY npt_hour;
```
```text
npt_hour | volume
---------+-------
11 | 47020
12 | 41230
13 | 46180
14 | 48610
(4 rows, 0.7 ms)
```
## Try it yourself
1. Make 5-minute candles for COAST on 2026-09-09, only the first 3 candles.
2. For each symbol in `ochl_day`, show the day with the biggest volume in September 2026. (Hint: `rank() OVER (PARTITION BY symbol ORDER BY volume DESC)` in a subquery.)
3. Show each broker's total traded amount (bought + sold) on 2026-09-10, biggest first, using `UNION ALL`.
Answers
1. ```sql
SELECT time, first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close, sum(volume) AS volume
FROM ochl_minutes
WHERE symbol = 'COAST' AND time IN '2026-09-09'
SAMPLE BY 5m
LIMIT 3;
```
2. ```sql
SELECT symbol, time, volume
FROM (
SELECT time, symbol, volume,
rank() OVER (PARTITION BY symbol ORDER BY volume DESC) AS r
FROM ochl_day
WHERE time IN '2026-09'
) t
WHERE r = 1
ORDER BY symbol;
```
Answer on the sample data: CITY 2026-09-10 (183040), COAST 2026-09-09 (186330), VOLT 2026-09-03 (145930).
3. ```sql
SELECT broker, sum(amount) AS turnover
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;
```
---
Next: [6. Web console](06-web-console.md)
---
# 6. Web console
- Category: Tutorial
## What you will learn
- How to start the server and open the console in a browser.
- Every part of the console screen: the Home menu, database list, objects, editor, results grid, chart, terminal and the panels on the right (Monitor, Health, Samples, Databases, Tools).
- How to use an access token, and how to keep the server safe.
## Start the server
```powershell
kdb serve market --backups backups
```
```text
KDB 0.2.0-dev
console http://127.0.0.1:9900
postgres 127.0.0.1:8832 (PostgreSQL protocol, no TLS; no password on this computer only)
data market
backups backups
access no token
commits synced to disk before they are confirmed (--sync-commits always)
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
recovery manifest copy: b, last shutdown: clean
Press Ctrl+C to stop.
```
Then open in Chrome, Edge or Firefox. While the server runs, it prints one line per request, for example `2026-09-11 14:07:33 GET /health 200 0 ms`.
`kdb serve market` serves **one** database folder (here the folder `market`). To serve **every database in the data folder** (chapter 1), start it without a folder:
```powershell
kdb serve --backups backups
```
```text
KDB 0.2.0-dev
console http://127.0.0.1:9900
postgres 127.0.0.1:8832 (PostgreSQL protocol, no TLS; no password on this computer only)
data D:/AMP/TechnivsProject/01_NChartPro/KDB/build/bin/data (from program folder; one folder per database)
default default (used when a request names no database)
backups backups/
access no token
commits synced to disk before they are confirmed (--sync-commits always)
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 on (SHOW AUDIT)
recovery the default database is created when it is first used
Press Ctrl+C to stop.
```
Then the console can switch between databases and create new ones (see [Databases](#databases) below). Each database backs up into its own sub-folder of the backup folder, so backups never mix.
| Option | Meaning | Default |
|---|---|---|
| `--data-dir FOLDER` | The data folder to serve (without a database folder argument) | `KDB_DATA_DIR`, else `data` next to `kdb.exe` |
| `--db NAME` | The database used when a request names none. In the data folder it is created when first used. | `default` (or the name you gave) |
| `--port N` | TCP port | `9900` |
| `--bind ADDRESS` | Which network address to listen on. `127.0.0.1` = this computer only. `0.0.0.0` = all networks. | `127.0.0.1` |
| `--backups FOLDER` | Backup folder. Needed for the **Backup now** button and `BACKUP DATABASE`. Also used by repairs. | not set |
| `--token SECRET` | Every request needs this token (see below). | no token |
| `--memory MB` | Memory for table data kept loaded. Data over this is read from disk again when needed, which is slower. On a PC or VPS with years of market history (about 1.5 GB on disk), `--memory 4096` keeps queries fast. | `512` |
- **Stop the server with Ctrl+C.** KDB then closes the database cleanly (`database closed cleanly`).
- If the window is closed or the process is killed, nothing committed is lost. The next start replays the log and shows `last shutdown: not clean (crash or kill)`.
- Only one server (or other `kdb` command) can use a database folder at a time.
To check from PowerShell that the page is there:
```powershell
$page = Invoke-WebRequest http://127.0.0.1:9900/ -UseBasicParsing
"HTTP $($page.StatusCode), $($page.Headers['Content-Type'])"
if ($page.Content -match '(.*?) ') { "title: $($Matches[1])" }
```
```text
HTTP 200, text/html; charset=utf-8
title: KDB console
```
The console needs no internet. Its files are plain files in the folder `web` next to `kdb` (in a source checkout: `web/console`). Chapter 1 lists where KDB looks. A new console needs only the files copied, not a new program. When KDB finds no console files, the page says so and lists the folders it looked in.
## The screen
```text
+-------------------------------------------------------------------------------------------+
| KDB [Home v] Database [market v] New database market v0.2.0-dev * Connected Help | <- top bar
+-----------------------+----------------------------------------------------------+--------+
| OBJECTS Refresh | [Run Ctrl+Enter] [Run all Ctrl+Shift+Enter] [History] Rows | Monitor|
| [Tables 6][Views][Trig]| 1 SELECT time, open, high, low, close, volume | Health | <- right side
| [Procs][Funcs][Timers] | 2 FROM ochl_day WHERE symbol = 'CITY'; | Samples|
| > brokers REL | Error line 2, column 8 unknown column ... | Databa.|
| > ochl_day TS |========================= (drag) ============================| Tools |
| ... | [1 SELECT time, ...] [2 Error] | ------ |
| > Indexes | [Grid][Chart] message [Export CSV] |Terminal|
| | # time open high ... | |
| |========================= (drag) ============================| |
| | TERMINAL [All][Queries][Writes][Timers][Errors] Pause Clear x| |
| | 14:19:02.114 HTTP 127.0.0.1 market 3 ms 2 rows SELECT ... | |
+-----------------------+----------------------------------------------------------+--------+
| 19 rows | parse 0.05 ms execute 0.34 ms round trip 12 ms Server 2026-09-11 14:19 | <- status bar
+-------------------------------------------------------------------------------------------+
```
The lines between the editor, the results and the terminal can be dragged. The console remembers the sizes.
### Top bar
| Part | What it shows |
|---|---|
| **Home** menu | The pages: **Home** (this page: editor, grid, chart), **Dashboard** (many charts on one page, chapter 20), **View data** (browse tables and change rows with forms), **Keep data** (how long each table keeps its rows), **Settings** (where the data is kept, and who may log in). The button shows where you are, for example `Home / Dashboard`. |
| **Database** list | The database everything on the page uses: editor, object lists, health, timers, backups. See [Databases](#databases). |
| **New database** | Creates a database in the server's data folder. Only shown when the server serves a data folder. |
| Folder name, version | The folder of the chosen database, and the KDB version |
| User and **Log out** | Only when the data folder has users (see [Login](#login-users)) |
| Connection dot | `Connected`, `Offline` (the server does not answer), `Token required` or `Login required` |
| **Help** | The help pages, the tutorial, keyboard shortcuts. Press **F1** on a word in the editor to open its help page. |
| Light / Dark button | Changes the colors. The browser remembers your choice. |
### Right side: Monitor, Health, Samples, Databases, Tools, Terminal
The buttons down the right side open a **panel** over the page. The same button closes it again; so do the **x** and `Esc`. The **pin** keeps a panel open beside the page. Drag its left edge to make it wider.
| Button | The panel |
|---|---|
| **Monitor** | Memory, processor, caches and the statements running now. **Cancel** stops a statement that was sent with a request id (chapter 8). |
| **Health** | Damaged files, backups, **Check now** and **Backup now** ([Health](#health-panel)). The button gets a red dot while a file is damaged. |
| **Samples** | Ready statements with **Insert** and **Run**. It opens by itself on your first visit. |
| **Databases** | Every database with its tables, size and folder. Click one to use it. **New database** and **Delete**. |
| **Tools** | **Purge trash** frees disk space in the whole data folder. **Delete all data** empties every table of the chosen database, after you type its name. |
| **Terminal** | Not a panel: shows or hides the terminal at the bottom of Home. |
### Terminal
The **terminal** under the results is a live log of what the server does: every statement, write, timer run, login and backup, from the console, from programs over HTTP, and from the PostgreSQL port.
- It is hidden when the page opens. It shows up by itself when you **Run** a statement. The **Terminal** button shows or hides it at any time, and counts the new lines while it is hidden.
- **All**, **Queries**, **Writes**, **Timers**, **Errors** show only that kind of line. A filter box keeps only lines with your text.
- Click a line to see all of its text. Double-click a statement to put it into the editor. Failed lines are red.
- Users who are not admins see only their own lines. The server keeps the last 5,000 events in memory (`--activity-log-size`).
### Databases
- The **Database** list at the top shows every database the server has. Pick one: the object lists and the health panel reload, and every statement you run goes to that database (the console adds `db=` to each request, chapter 8).
- The browser **remembers** your choice. If that database is gone, the console uses the server's default database.
- **New database** asks for a name (letters, digits, `_` and `-`, at most 64). Wrong names are refused at once. The new database is chosen right away and is empty.
- **Delete** next to a database in the **Databases** panel (right-hand rail) drops it. You type its name to confirm. Only a server admin sees the button. Like `DROP DATABASE`, it moves the folder to `_trash` in the data folder, and it is refused while a request or shell still uses the database.
- `USE demo;` in the editor also switches the list to `demo`.
- `CREATE DATABASE x;` and `DROP DATABASE x;` in the editor work too; the list reloads after them. If you drop the chosen database, the console goes back to the default database. `DROP DATABASE` never deletes data: the folder moves to `_trash` in the data folder.
- With `kdb serve market` (one folder), the list has just that database and there is no **New database** button.
The list comes from `GET /api/databases`:
```powershell
$d = Invoke-RestMethod http://127.0.0.1:9900/api/databases
"default: $($d.default) can_create: $($d.can_create)"
$d.databases | Select-Object name, tables, default | Format-Table -AutoSize
```
```text
default: market can_create: False
name tables default
---- ------ -------
market 6 True
```
### Objects (left side)
The left side has one tab for each kind of object: **Tables**, **Views**, **Triggers**, **Procedures**, **Functions** and **Timers**. Each tab shows how many there are. Indexes are in the Tables tab, under the tables.
- Click the small arrow of a view, trigger, procedure or function to see its statement. **Open in editor** puts it into the editor.
- **Timers** run SQL by themselves on a schedule, for example `CALL refresh_prices();` every 5 minutes. **New timer** opens a form. The server runs them, also when no browser is open.
- **+ Folder** makes a folder, and you drag objects onto it. Folders only tidy this list. They are kept in the table `_console_groups`, so every browser sees them.
In the Tables tab:
- Every table with a badge: `TS` = time-series, `REL` = relational, and the partition (`DAY`, `MONTH`, `YEAR`).
- Click the small arrow to see the columns with their types. Marks: `TS` = time column, `PK` = primary key, `FK` = REFERENCES, `FX` = computed column.
- **Click** a table or column name: its name is put into the editor at the cursor.
- **Double-click** a table name: runs `SHOW COLUMNS FROM `.
- **Refresh** reloads the lists and the health panel. They also reload by themselves after `CREATE`, `DROP`, `ALTER` or `RENAME`, and every 30 seconds.
### Editor
- Type SQL. Keywords, text and numbers get colors. Line numbers are on the left.
- The text stays in your browser. It is still there when you open the console again.
- `Tab` puts in two spaces.
- **Rows** (top right) is the most rows a query returns: 1,000 to 1,000,000. When a result is cut, the console says `Row limit reached`.
### Keyboard shortcuts
| Keys | What it does |
|---|---|
| `Ctrl+Enter` | Run the statement under the cursor. If you selected text, run only the selection. |
| `Ctrl+Shift+Enter` | Run everything in the editor |
| `Tab` | Insert two spaces |
| `Escape` | Close the History list, or the open panel |
| `F1` | Open the help page of the word under the cursor |
| `Ctrl+C` (in the grid) | Copy the selected cell |
| Mac | Use `Cmd` instead of `Ctrl` |
### Running statements
- Statements are split at `;`. "The statement under the cursor" is the one between the `;` before and after the cursor.
- With many statements, each result gets a **tab** above the results (`1 SELECT ...`, `2 ...`).
- If a statement fails, a red **error bar** appears under the editor. The position (`line 2, column 8`) is counted in **your editor**, and the line is marked red. Click the position to jump there.
- The statements before the error ran and are saved. The error tab says so: `Before it: 1 statement(s) ran and are kept`.
- Statements like INSERT show a message (`3 row(s) inserted`) and the rows affected.
### History
**History** lists the last 50 statements you ran, newest first, with the time. Click one to put it back into the editor. The history is kept in this browser only.
### Grid
The grid shows the rows of a result.
- The column header shows the name and the type (`DOUBLE`, `TIMESTAMP`, ...). Numbers are right-aligned.
- **Click a header** to sort: first click small-to-big, second click big-to-small, third click back to the original order. Sorting happens in the browser, on the rows already loaded.
- **Drag the right edge** of a header to change the column width.
- **Click a cell** to select it. `Ctrl+C` or **double-click** copies its value.
- Empty values show as `null`. Long text shows fully when you hover over it.
- The grid is fast also with many rows: it only draws the rows you can see.
### Chart
Click **Chart** above the result. The chart button works when the result has a number column. KDB picks the chart from the columns, for example:
- a `TIMESTAMP` or `DATE` column and columns named `open`, `high`, `low`, `close`: **candles**, with bars for a `volume` column;
- a time column and other numbers: **lines**;
- a text column and a number: **bars**; one row of numbers: a big **KPI number**.
Change the chart with the chart type list above the chart: line, area, bars, pie, donut, treemap, heatmaps, histogram, box plot, scatter, gauge, table and more. **Columns** chooses the columns, **Indicators** adds SMA, EMA, Bollinger, RSI, MACD or stochastic. **Chapter 20 explains every chart type and the dashboards.**
If the result has a SYMBOL or STRING column with more than one value (for example many symbols), a **symbol list** appears. The chart shows one symbol at a time (lines with up to 8 symbols get one line per symbol).
| Action | What it does |
|---|---|
| Mouse wheel | Zoom in and out around the mouse |
| Drag (hold the left button) | Move left and right |
| Double-click | Reset the view (candles: the last 200 bars) |
| Move the mouse | Crosshair. The top line shows date, O, H, L, C, change % and volume of that candle. The price and the date are marked on the axes. |
| Click a name in the legend | Hide or show that line |
| Arrow keys, `+`, `-`, `Home` (click the chart or press Tab first) | Move from candle to candle, zoom, reset |
- Candles have no gaps for days without trading (Friday, Saturday, holidays): each bar is one row. Untick **No gaps** to see real time.
- Times on the chart axis are shown in **your computer's time zone** (Nepal time on a PC set to Nepal). The grid shows UTC.
- Good queries for the chart: `SELECT time, open, high, low, close, volume FROM ochl_day WHERE symbol = 'CITY'`, or the candle queries of chapter 5.
### Export CSV
**Export CSV** downloads all rows of the result as a CSV file. It runs the statement **again** on the server (up to 1,000,000 rows), so it is only offered for `SELECT`, `SHOW` and `EXPLAIN`. The file name comes from the table name, for example `ochl_day.csv`. Times are UTC in ISO format.
### Health panel
| Line | Meaning |
|---|---|
| `OK - no damaged files` (green) / `DAMAGED (n)` (red) | Damaged files that KDB knows about, with their names |
| Last backup | How long ago (`just now`, `3 h ago`), `never`, or `no backup folder` |
| Data | Size of the data files and how many |
| Log LSN | Number of the last change in the log. It grows with every change. |
| Opened | When the server opened the database |
| Recovery at open | What KDB did when it opened the database. It opens by itself if there was a problem. |
| **Check now** | Reads every block of every file and the log. Example message: `OK: 11 files (76.1 KB) and 1 log segments read in 1.50 ms. No damage.` |
| **Backup now** | Makes a verified backup into the `--backups` folder. Example message: `Backup 20260911-083519-806 created and verified: 9 files, 222.3 KB.` The button is off when the server has no backup folder. |
If **Check now** finds damage, stop the server and run `kdb repair` (chapter 9).
### Status bar (bottom)
- Left: the number of rows, and the times: `parse` and `execute` on the server, `round trip` including the network.
- Right: the server's clock, in your local time zone.
## Login (users)
When the data folder has users (chapter 16), the console shows **Log in** instead of the token box. Type the user name and password. After the login the top bar shows the user and role, for example `boss (admin)`, and a **Log out** button. The top bar dot says `Login required` while nobody is logged in.
- The login is kept in a cookie that scripts cannot read (`HttpOnly`) and that works only on this site (`SameSite=Strict`). It ends after 12 hours, at **Log out**, when the server restarts, or when the password changes.
- A wrong password keeps the form open with `Wrong user name or password.` After 5 wrong tries, wait a moment before the next one.
- Each statement runs with the user's role. A refused statement shows the error, for example `INSERT needs role write on database 'shop'; user 'report' has read`.
- The history of the editor never keeps passwords: `PASSWORD 'secret'` and the text in `password_hash('...')` are saved as `'***'`.
## Access token
Start the server with a token:
```powershell
kdb serve market --token change-me-please
```
- The console asks for the token once (**Access token required**). The browser keeps it (local storage and a cookie for this site only).
- Programs send it in a header: `Authorization: Bearer change-me-please` (chapter 8).
- A wrong or missing token gets HTTP 401.
- `/health` works without a token. It only says `auth_required: true`.
Choose a long random token, for example:
```powershell
[Convert]::ToBase64String((1..24 | ForEach-Object { Get-Random -Maximum 256 }) -as [byte[]])
```
## Security notes
| Situation | What to do |
|---|---|
| Only you, on this PC | Default (`--bind 127.0.0.1`, no token) is fine. |
| Other PCs in the office must connect | `--bind 0.0.0.0 --token `. Open the port only in the Windows firewall for your office network. |
| Access over the internet | Do **not** open port 9900 to the internet. Put a reverse proxy with HTTPS (for example nginx or Caddy) in front, keep `--bind 127.0.0.1`, and use `--token`. |
What KDB does to protect you:
- Without a token, KDB listens on `127.0.0.1` only. If you use `--bind 0.0.0.0` **without** a token, the server prints a big **WARNING**: anyone who can reach the port can read, change and delete all data.
- The HTTP API refuses requests from other web sites (HTTP 403 `cross-site requests are not allowed`), so a bad web page in your browser cannot use your local KDB.
- Without a token, the server only accepts the host names `localhost` and `127.x.x.x` (HTTP 403 otherwise). This stops "DNS rebinding" attacks. A reverse proxy that changes the `Host` header therefore needs `--token`.
- `GET /exec` only runs statements that read. Changes must use `POST` (chapter 8).
- There is **no HTTPS** inside KDB. Use a reverse proxy.
- The token gives full access: everyone with the token can change and delete data. For people and programs that should do less, make **users** with roles instead (chapter 16).
## Try it yourself
1. Start the server with a backup folder. Make a backup with the button. Open the **Health** panel on the right.
2. Run the weekly candle query from chapter 5 for VOLT and look at it as a chart. Zoom in, then double-click.
3. Run `SELECT clsoe FROM ochl_day;` on line 3 of the editor. Which position does the error bar show?
Answers
1. `kdb serve market --backups backups`, then **Backup now**. "Last backup" changes to `just now`.
2. ```sql
SELECT time, first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close, sum(volume) AS volume
FROM ochl_day WHERE symbol = 'VOLT' SAMPLE BY 1w WEEK START SUNDAY;
```
Then click **Chart**.
3. `line 3, column 8`. The console counts lines in your editor, not in the statement.
---
Next: [7. Command line and scripts](07-command-line-and-scripts.md)
---
# 7. Command line and scripts
- Category: Tutorial
## What you will learn
- When a command means a database in the data folder, and when a folder path.
- The three output formats of `kdb sql`: table, CSV and JSON.
- How to use KDB in PowerShell and bash scripts (loops, exit codes, UTF-8 text).
- How to make a daily backup task with Windows Task Scheduler.
The examples use the database `market` with the sample data.
## Database names and folder paths
Every command that needs a database (`sql`, `shell`, `serve`, `info`, `check`, `repair`, `backup`) takes a **name** or a **path**:
| You write | KDB uses |
|---|---|
| A name (only letters, digits, `_`, `-`), and the data folder has a database with that name | That database in the data folder (chapter 1). Upper or lower case does not matter. |
| A name, and the data folder has no database with that name | The folder with that name in the current folder, like before. It is created if it does not exist. |
| A path (it has `\`, `/`, `:` or `.`): `.\market`, `D:\kdb\market` | Always that folder |
| Nothing (`kdb shell`, `kdb serve`) | The data folder: the database `default` for the shell; all databases for the server |
- To be sure you mean the folder in the current folder, write `.\market` (Linux: `./market`).
- To make a database in the data folder, use `CREATE DATABASE` in `kdb shell` (or in the console, or over HTTP).
- With a **name**, `kdb backup` and `kdb repair --backups` use the sub-folder `\`, the same place `kdb serve` uses. With a path, they use `` as before.
```powershell
$env:KDB_DATA_DIR = "kdb-data"
# Make a database in the data folder (kdb shell runs CREATE DATABASE).
"CREATE DATABASE reports;" | kdb shell | Out-Null
# A name that is in the data folder means that database, from any folder.
kdb sql reports "CREATE TABLE daily (day DATE PRIMARY KEY, trades LONG); INSERT INTO daily VALUES ('2026-09-10', 32)"
kdb sql reports "SELECT * FROM daily"
kdb info reports | Select-Object -First 1
"reports in the data folder: $(Test-Path kdb-data\reports) in the current folder: $(Test-Path reports)"
# A name that is not in the data folder is a folder in the current folder, as before.
kdb sql market "SELECT count(*) AS brokers FROM brokers"
# A path always means that folder: .\reports is a new, empty database here.
kdb sql .\reports "SHOW TABLES"
"reports in the current folder now: $(Test-Path reports)"
```
```text
table 'daily' created
1 row(s) inserted
day | trades
-----------+-------
2026-09-10 | 32
(1 row, 0.4 ms)
database kdb-data/reports
reports in the data folder: True in the current folder: False
brokers
-------
5
(1 row, 0.2 ms)
name | kind | partition | columns | rows
-----+------+-----------+---------+-----
(0 rows, 0.0 ms)
reports in the current folder now: True
```
## Output formats
### table (default)
For people. Numbers are right-aligned. At most 1000 rows are shown (change with `--max-rows`).
```powershell
kdb sql market "SELECT symbol, ltp, pct_change FROM live_data LATEST ON timestamp PARTITION BY symbol"
```
```text
symbol | ltp | pct_change
-------+-------+-----------
CITY | 527.1 | 2.71
COAST | 401.9 | 0.7
TELCO | 897.7 | 1.9
VOLT | 184 | -0.86
(4 rows, 0.4 ms)
```
### csv
For Excel and scripts. The first line has the column names. There is no row limit.
```powershell
kdb sql market "SELECT symbol, ltp, pct_change FROM live_data LATEST ON timestamp PARTITION BY symbol" --format csv
```
```text
symbol,ltp,pct_change
CITY,527.1,2.71
COAST,401.9,0.7
TELCO,897.7,1.9
VOLT,184,-0.86
```
- Text with a comma, quote or line break is put in `"double quotes"`.
- NULL is an empty field.
- Times are UTC, like `2026-09-10T05:15:00.000Z`.
- Messages like `3 row(s) inserted` are not printed in CSV.
- With many SELECT statements, the CSV blocks come one after another, each with its own header line. Use one SELECT per command when you save CSV.
To save a file: `kdb sql market "SELECT ..." --format csv > prices.csv`. (In Windows PowerShell 5.1, `>` writes UTF-16. Use `| Set-Content prices.csv -Encoding UTF8` if another program must read the file.)
### json
For programs. One JSON array with one object per statement.
```powershell
kdb sql market "SELECT symbol, ltp, pct_change FROM live_data LATEST ON timestamp PARTITION BY symbol" --format json
```
```text
[{"statement":"SELECT symbol, ltp, pct_change FROM live_data LATEST ON timestamp PARTITION BY symbol","columns":[{"name":"symbol","type":"SYMBOL"},{"name":"ltp","type":"DOUBLE"},{"name":"pct_change","type":"DOUBLE"}],"rows":[["CITY",527.1,2.71],["COAST",401.9,0.7],["TELCO",897.7,1.9],["VOLT",184,-0.86]],"rows_affected":0,"message":"","truncated":false}
]
```
| Field | Meaning |
|---|---|
| `statement` | The statement, as KDB understood it |
| `columns` | Name and type of each column |
| `rows` | The rows. Each row is a list of values in column order. |
| `rows_affected` | Rows changed by INSERT, UPDATE or DELETE |
| `message` | For example `3 row(s) inserted` |
| `truncated` | `true` if there were more rows than `--max-rows` |
Values in JSON: numbers are numbers; TIMESTAMP and DATE are text (UTC); DECIMAL is text (to stay exact); NULL is `null`.
If a statement fails, the JSON of the statements before it is printed, and the error goes to the error output (stderr) with exit code 1.
### --max-rows
```powershell
kdb sql market "SELECT time, symbol, close FROM ochl_minutes" --max-rows 3
```
```text
time | symbol | close
-------------------------+--------+------
2026-09-09T05:15:00.000Z | CITY | 512.1
2026-09-09T05:16:00.000Z | CITY | 511.9
2026-09-09T05:17:00.000Z | CITY | 511.9
(3 rows, more not shown, 0.6 ms)
```
`--max-rows 0` means no limit. For a fixed number of rows in your result, `LIMIT` in the SQL is better: it also makes the query faster.
## PowerShell
### CSV to objects: ConvertFrom-Csv
```powershell
$rows = kdb sql market "SELECT symbol, ltp, pct_change FROM live_data LATEST ON timestamp PARTITION BY symbol" --format csv | ConvertFrom-Csv
# Every value is text. Use [double] to compare numbers.
$rows | Where-Object { [double]$_.pct_change -gt 1 } | Format-Table -AutoSize
```
```text
symbol ltp pct_change
------ --- ----------
CITY 527.1 2.71
TELCO 897.7 1.9
```
Without `[double]`, PowerShell compares text: `"10" -gt "9"` is false.
### JSON to objects: ConvertFrom-Json
In Windows PowerShell 5.1, join the lines with `Out-String` first:
```powershell
$json = kdb sql market "SELECT symbol, sum(quantity) AS shares FROM trade_log WHERE date IN '2026-09-10' GROUP BY symbol ORDER BY shares DESC" --format json | Out-String
$result = ConvertFrom-Json $json
# One result per statement. rows is a list of rows; each row is a list of values.
foreach ($row in $result[0].rows) {
"{0,-6} {1,6} shares" -f $row[0], $row[1]
}
```
```text
COAST 2850 shares
CITY 910 shares
TELCO 770 shares
VOLT 400 shares
```
JSON keeps numbers as numbers, so you do not need `[double]`.
### A loop: one CSV file per symbol
```powershell
foreach ($symbol in @("CITY", "COAST", "VOLT")) {
$csv = kdb sql market "SELECT symbol, time, close FROM ochl_day WHERE symbol = '$symbol' AND time IN '2026-09' ORDER BY time" --format csv
$csv | Set-Content "close-$symbol.csv" -Encoding UTF8
"$symbol : $(($csv | Measure-Object).Count - 1) rows saved to close-$symbol.csv"
}
```
```text
CITY : 8 rows saved to close-CITY.csv
COAST : 8 rows saved to close-COAST.csv
VOLT : 8 rows saved to close-VOLT.csv
```
Inside `"double quotes"`, PowerShell puts in the value of `$symbol`. The SQL text uses `'single quotes'`, so both work together.
**Never put text from users directly into SQL.** A symbol like `x' OR '1'='1` would change the query. Check such values first, for example with `if ($symbol -notmatch '^[A-Z0-9-]+$') { throw "bad symbol" }`.
### Exit codes in scripts
```powershell
kdb sql market "INSERT INTO brokers VALUES (58, 'Northgate Securities')" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Output "The insert failed (exit code $LASTEXITCODE). Stop here."
}
kdb check market | Out-Null
switch ($LASTEXITCODE) {
0 { Write-Output "check: OK" }
2 { Write-Output "check: DAMAGE FOUND - run kdb repair" }
default { Write-Output "check: could not run (exit code $LASTEXITCODE)" }
}
```
```text
The insert failed (exit code 1). Stop here.
check: OK
```
- `0` = OK, `1` = error, `2` = damage, `3` = the database needs a newer KDB (chapter 2).
- `$LASTEXITCODE` changes after **every** program. Read it right after the `kdb` command.
- `$ErrorActionPreference = "Stop"` does **not** stop a script when `kdb` fails. Check `$LASTEXITCODE` yourself.
### Non-English text (UTF-8)
Windows PowerShell 5.1 sends text into a pipe as ASCII by default. Letters outside English (Nepali, Chinese, accented letters, ...) then become `?`. Set UTF-8 first:
```powershell
# Windows PowerShell 5.1 sends pipe text as ASCII unless you say UTF-8.
$OutputEncoding = New-Object System.Text.UTF8Encoding $false
@"
CREATE TABLE IF NOT EXISTS names_np (symbol SYMBOL PRIMARY KEY, name_np STRING);
INSERT OR REPLACE INTO names_np VALUES ('TELCO', 'नेपाल दूरसञ्चार');
"@ | Set-Content names.sql -Encoding UTF8
Get-Content names.sql -Raw -Encoding UTF8 | kdb sql market -
kdb sql market "SELECT symbol, name_np FROM names_np"
```
```text
table 'names_np' created
1 row(s) inserted
symbol | name_np
-------+----------------
TELCO | नेपाल दूरसञ्चार
(1 row, 0.2 ms)
```
- Save `.sql` files as **UTF-8**.
- Text in any language also works directly in the command line (`kdb sql market "INSERT ... 'नेपाल'"`). If you see `????` after a pipe, see [chapter 12](12-troubleshooting.md#text-in-other-languages-looks-broken).
- PowerShell 7 already uses UTF-8.
## Daily backup script
This script makes a backup, deletes old backups, checks the database and writes a log. Run it every trading day **after 15:00 Nepal time**, when the market is closed.
It works when **no server is running** on the database (`kdb backup` needs the database closed). If `kdb serve` runs all the time, see [the server version](#if-kdb-serve-runs-all-day) below.
Save as `daily-backup.ps1`:
```powershell
# daily-backup.ps1 - run after the market closes (15:00 Nepal time).
# Use full paths in a scheduled task, for example:
# $Kdb = "D:\KDB\build\bin\kdb.exe"; $Data = "D:\kdb\market"; $Backups = "E:\kdb_backups"
$Kdb = "kdb"
$Data = "market"
$Backups = "backups"
$Log = "backup-log.txt"
Add-Content $Log "---- $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
# 2>&1 keeps error lines too; "$_" turns each line into plain text.
$out = & $Kdb backup $Data $Backups --keep-daily 7 --keep-weekly 4 2>&1 | ForEach-Object { "$_" }
$code = $LASTEXITCODE
Add-Content $Log $out
Add-Content $Log "backup exit code: $code"
if ($code -ne 0) { Write-Output "BACKUP FAILED, see $Log"; exit $code }
$out = & $Kdb check $Data 2>&1 | ForEach-Object { "$_" }
$code = $LASTEXITCODE
Add-Content $Log $out
Add-Content $Log "check exit code: $code"
if ($code -ne 0) { Write-Output "CHECK FOUND A PROBLEM, see $Log"; exit $code }
Write-Output "backup and check OK"
```
Run it:
```powershell
powershell -ExecutionPolicy Bypass -File .\daily-backup.ps1
```
```text
backup and check OK
```
The log file:
```text
---- 2026-09-23 17:54:29
backup created and verified: backups/20260923-120929-906
10 files, 190.8 KB, log up to 34
backup exit code: 0
checked 12 files (76.2 KB) and 1 log segments
OK: no damage found
check exit code: 0
```
Notes:
- **Put the backups on another disk** than the database (for example `E:\kdb_backups`). One broken disk must not destroy both.
- `--keep-daily 7 --keep-weekly 4` keeps the newest backup of each of the last 7 days and of the last 4 weeks. Older backups are deleted, and the script prints `removed old backup ...`.
- Backup folder names use **UTC** time: `20260923-120929-906` = 12:09:29 UTC = 17:54 Nepal time.
### If kdb serve runs all day
A running server keeps the database open, so `kdb backup` fails. Ask the server to make the backup instead. Start the server with `--backups E:\kdb_backups`, then:
```powershell
# daily-backup-server.ps1
$r = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/api/backup"
"backup $($r.name): $($r.files) files, $($r.bytes) bytes"
$c = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/api/check"
if (-not $c.ok) { Write-Output "CHECK FOUND A PROBLEM: $($c.problems -join '; ')"; exit 2 }
```
(If the server uses `--token`, add `-Headers @{ Authorization = "Bearer " }`.) The server keeps 7 daily and 4 weekly backups by itself. Chapter 8 shows the JSON of these two calls.
Simpler: no script at all. `kdb serve ... --backups E:\kdb_backups --backup-schedule "daily 16:00" --backup-copy F:\kdb_copy` makes and checks the backups by itself, while the server keeps working ([chapter 21](21-protection.md#part-c-backups-on-a-schedule)).
### Windows Task Scheduler
Register the script to run Sunday to Thursday at 15:30. Run PowerShell **as Administrator**. Change the paths.
```powershell
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File D:\kdb-work\daily-backup.ps1" `
-WorkingDirectory "D:\kdb-work"
$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday,Monday,Tuesday,Wednesday,Thursday -At 15:30
Register-ScheduledTask -TaskName "KDB daily backup" -Action $action -Trigger $trigger `
-Description "Backup and check the market KDB database after market close"
```
(These Task Scheduler commands were **not run** while writing this tutorial, so they would not change the test PC. The script itself was run.)
- The time `15:30` is the **computer's** clock. Set the PC to Nepal time, or change the time.
- Test it once: `Start-ScheduledTask -TaskName "KDB daily backup"`, then read the log file.
- In a task, use **full paths** in the script, because the task does not have your `PATH`.
## bash (Linux, macOS, Git Bash)
The same ideas in bash. Save as `report.sh`:
```bash
#!/usr/bin/env bash
set -u
DB=market
kdb sql "$DB" "SELECT symbol, close FROM ochl_day WHERE time IN '2026-09-10'" --format csv > closes.csv
echo "saved $(( $(wc -l < closes.csv) - 1 )) rows"
kdb sql "$DB" "SELECT nope FROM ochl_day" > /dev/null 2>&1
code=$?
if [ "$code" -ne 0 ]; then
echo "query failed with exit code $code"
fi
kdb sql "$DB" - <<'SQL'
SELECT count(*) AS trades FROM trade_log;
SQL
```
```bash
bash ./report.sh
```
```text
saved 3 rows
query failed with exit code 1
trades
------
32
(1 row, 0.3 ms)
```
- `<<'SQL' ... SQL` sends many lines of SQL into `kdb sql ... -`. The quotes around `'SQL'` stop bash from changing `$` signs in the SQL.
- `kdb sql "$DB" - < file.sql` sends a file.
- On Linux you can use `cron` for the daily backup: `30 15 * * 0-4 /home/kdb/daily-backup.sh` (Sunday=0 to Thursday=4, computer time).
(Tested with Git Bash on Windows.)
## Speed options for `kdb serve`
| Option | Default | What it does |
|---|---|---|
| `--memory ` | 512 | Table data kept in memory. More is faster for big tables. Data stays on disk; only what queries need is loaded. |
| `--threads ` | half of the CPU threads | Threads for big queries and bulk writes. KDB never uses more than half of the CPU threads. |
| `--cache-mb ` | 64 | Memory for results of repeated queries. `0` turns the cache off. |
| `--sync-commits always\|batch` | always | `batch` is much faster for many small writes; a power cut can lose the last few confirmed writes, but never damages data. With `always`, writers that commit at the same moment still share one wait for the disk (group commit). |
| `--warm-indexes on\|off` | on | Build every index on a background thread right after a database opens, so the first query after a restart finds it ready. `off`: an index is built when a query first needs it. |
| `--reader-yield on\|off` | off | `on`: reads do not wait for a long trigger or `CALL`; they read the last committed rows. Also `KDB_READER_YIELD`. |
**The result cache.** When the same `SELECT` comes again (a dashboard that refreshes, a live page) and no table it reads has changed, KDB answers from memory in a few microseconds.
- Any change to a table it read (INSERT, UPDATE, DELETE, line protocol, rollback) makes KDB compute the result again.
- Changes of views, functions, procedures and tables clear the whole cache.
- Queries with `now()`, `random()`, `gen_random_uuid()`, password functions and similar are never kept.
- A `SELECT` inside your own open transaction is never taken from the cache.
```powershell
.\kdb serve market --memory 2048 --cache-mb 128
```
## Protection options for `kdb serve`
[Chapter 21](21-protection.md) explains them. They cost almost no speed.
| Option | Default | What it does |
|---|---|---|
| `--statement-timeout 30s` | 60s | Stop a statement that runs longer (`0` = no limit) |
| `--max-result-memory 512MB` | 1GB | Stop a query that keeps more rows in memory |
| `--max-groups N` | 5000000 | Stop a `GROUP BY` with more groups. (A `GROUP BY` that needs more than half of `--max-result-memory` does not stop: it puts groups in temporary files on disk and gives the same result, only slower.) |
| `--max-queries-per-user N` | 32 | Statements of one user at the same time |
| `--safe-updates` | off | Refuse `UPDATE`/`DELETE` without `WHERE` |
| `--read-only` | off | No changes at all (a report server) |
| `--trash-days N` | 7 | `DROP TABLE` keeps the table for `UNDROP TABLE` (also `kdb sql` and `kdb shell`) |
| `--audit on\|off`, `--audit-keep-days N` | on, forever | The audit log of logins, user changes, DDL and backups |
| `--backup-schedule "daily 16:00"` | off | Backups by themselves (needs `--backups`) |
| `--backup-copy ` | none | A checked second copy of each scheduled backup |
| `--backup-warn-hours N` | 26 | Warn when the last good backup is older |
| `--drain-timeout 30s` | 30s | On stop (Ctrl+C or a service stop), running requests may finish this long; new ones get HTTP 503 |
| `--activity-log-size N` | 5000 | Recent events kept in memory for the console's Terminal (`0` = off) |
`kdb sql` and `kdb shell` have no timeouts or limits. `kdb user reset-2fa ` turns two-factor login off while the server is stopped.
## Try it yourself
1. Save all CITY minute candles of 2026-09-10 to `city-minutes.csv`.
2. Write a PowerShell line that prints only the number of trade_log trades on 2026-09-10 (just the number).
3. Change the daily backup script so it also writes the size of the newest backup into the log (hint: `kdb backups`).
Answers
1. ```powershell
kdb sql market "SELECT * FROM ochl_minutes WHERE symbol = 'CITY' AND time IN '2026-09-10' ORDER BY time" --format csv | Set-Content city-minutes.csv -Encoding UTF8
```
2. ```powershell
(kdb sql market "SELECT count(*) AS n FROM trade_log WHERE date IN '2026-09-10'" --format csv | ConvertFrom-Csv).n
```
The answer is `24`.
3. After the backup lines, add:
```powershell
$list = & $Kdb backups $Backups 2>&1 | ForEach-Object { "$_" }
Add-Content $Log $list[1] # line 2 = the newest backup
```
---
Next: [8. HTTP API](08-http-api.md)
---
# 8. HTTP API
- Category: Tutorial
## What you will learn
- Every HTTP route of `kdb serve`, with `curl` examples and the real JSON answers.
- How one server serves many databases: `db=` and `/api/databases`.
- How to call KDB from PowerShell, Python, Node, Go and C#.
- The rules: GET only reads, row limits, errors, access token, users and logins.
- Fast loads with the line protocol (`POST /write`), all-or-nothing requests (`atomic=1`), results by column (`format=columns`) and the `ETag`.
Start the server first (with a backup folder, for the backup routes). `kdb serve market` serves the one database folder `market`; [Many databases](#many-databases) shows a server for a whole data folder.
```powershell
kdb serve market --backups backups
```
All examples use `http://127.0.0.1:9900`. On Windows, write `curl.exe` (in PowerShell, `curl` alone is another command).
## Routes
| Method | Path | What it does | Login needed |
|---|---|---|---|
| GET | `/health` | Is the server alive? Version, clock. | no |
| POST | `/api/login` | Log in with a user name and password: a cookie and a token (chapter 16) | no |
| POST | `/api/logout` | End the login | no |
| GET | `/api/me` | The logged-in user and role | yes |
| GET | `/exec?query=SQL` | Run SQL that **reads**. JSON result. | yes |
| POST | `/exec` | Run **any** SQL (the body is the SQL). JSON result. | yes |
| DELETE | `/exec/` | Stop the running request sent with `X-KDB-Request-Id: ` ([Stop a slow request](#stop-a-slow-request)) | yes |
| GET or POST | `/exp?query=SQL` | Run a SELECT and download CSV | yes |
| POST | `/imp?name=table` | Load a CSV file into a table, as QuestDB does ([/imp](#imp-load-csv)) | yes |
| GET | `/api/info` | Tables, columns, sizes, damage, last backup | yes |
| POST | `/api/check` | Read every file and the log; report damage | yes |
| POST | `/api/backup` | Make a verified backup (needs `--backups`) | yes |
| GET | `/api/backups` | List backups, newest first | yes |
| GET | `/api/audit?limit=N&where=...` | The audit log: logins, user changes, DDL, backups (server admins; chapter 21). `kind=data` reads the data-change rows of the database | yes |
| GET | `/api/databases` | List the databases: name, folder, tables, bytes | yes |
| POST | `/api/databases` | Create a database (`name=` or `{"name":""}`) | yes |
| DELETE | `/api/databases/` | Drop a database (its folder moves to `_trash`) | yes |
| POST | `/cursor` | Run one SELECT and keep its rows (see [Cursors](#cursors-read-a-big-result-bit-by-bit)) | yes |
| GET | `/cursor/?fetch=N` | The next N kept rows | yes |
| DELETE | `/cursor/` | Close the cursor | yes |
| POST | `/session`, DELETE `/session/` | A transaction over many requests: send `X-KDB-Session: ` with each `/exec` (chapter 15) | yes |
| POST | `/write` (also `/api/v2/write`) | Load rows in the InfluxDB line format, the fast way for feeds and sensors ([Line protocol](#post-write-line-protocol)) | yes |
| GET | `/api/stats` | Memory, processor, running statements, open databases (the console's **Monitor**) | yes |
| GET | `/api/disk` | What a database takes on disk, part by part | yes |
| GET | `/api/activity` | Recent server events (the console's **Terminal**) | yes |
| GET | `/api/trigger-queue` | The queues of deferred triggers (chapter 13) | yes |
| GET, POST | `/api/timers` | SQL that runs on a schedule (admins) | yes |
| GET, POST, DELETE | `/api/pins` | Statements whose results are kept ready (admins; chapter 17) | yes |
| GET, POST | `/api/settings`, POST `/api/restart` | Saved settings in `kdb.conf` (the console's **Settings** page); restart in place (admins) | yes |
| GET | `/` | The web console (chapter 6), from the folder `web/` next to `kdb` | no |
| GET | `/help/` | The help pages, from the folder `help/` next to `kdb` | no |
| GET | `/skill.md` | The Claude skill file, from the folder `skill/` next to `kdb` | no |
"Login needed" applies when the server was started with `--token`, or when its data folder has users (chapter 16). Then each user may only do what their role allows; other requests get HTTP 403.
Every route that reads or changes data (all except `/health` and `/api/databases`) works on **one database**: the one named by the parameter `db=` or the header `X-KDB-Database: `. Without them, the server's default database is used. With `kdb serve market` the server has only that database, so you can leave `db` out. See [Many databases](#many-databases).
Parameters for `/exec` and `/exp`:
| Parameter | Meaning | Default |
|---|---|---|
| `db` | The database (also for `/api/info`, `/api/check`, `/api/backup`, `/api/backups`, `/write`) | the default database |
| `query` | The SQL (for GET, or as a form field in POST) | - |
| `limit` | Most rows per statement. At most 1,000,000. `limit=0` means 1,000,000. QuestDB style `limit=10,500` uses the second number. | `/exec`: 10,000; `/exp`: 1,000,000 |
| `compat` | `compat=0` leaves out the QuestDB-style copy of the last result (see below) | `1` |
| `format` | (`/exec` only) `columns`: each `dataset` as one list per column ([format=columns](#format-columns-and-etag)) | `rows` |
| `atomic` | (`/exec` only) `atomic=1` (or the header `X-KDB-Atomic: 1`): all statements of the body are one transaction, all or nothing ([atomic=1](#atomic-1-all-or-nothing)) | `0` |
| `cache` | `cache=0`: do not use the result cache for this request (for timing tests) | `1` |
| `filename` | (`/exp` only) name of the downloaded file | `kdb-export.csv` |
## GET /health
```powershell
curl.exe -s http://127.0.0.1:9900/health
```
```json
{"status":"ok","version":"0.2.0-dev","server_time_ms":1789114666217,"auth_required":false,"login":false}
```
`server_time_ms` is the server clock in milliseconds since 1970 (UTC). `auth_required` is `true` when requests need a token or a login. `login` is `true` when the data folder has users (the console then shows a login form). Use `/health` for monitoring: it never needs a token and never touches the data.
## /exec: run SQL
### GET only reads
Use `--data-urlencode` so curl makes the URL safe (spaces, quotes, `%`):
```powershell
curl.exe -s -G http://127.0.0.1:9900/exec --data-urlencode "query=SELECT id, name FROM brokers WHERE id < 40"
```
The answer (formatted for reading; the server sends it on one line):
```json
{
"query": "SELECT id, name FROM brokers WHERE id < 40",
"results": [
{
"statement": "SELECT id, name FROM brokers WHERE id < 40",
"columns": [ { "name": "id", "type": "INT" }, { "name": "name", "type": "STRING" } ],
"dataset": [ [1, "Keystone Securities"], [34, "Vision Securities"] ],
"count": 2,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": { "parse_us": 7, "execute_us": 167 }
}
],
"columns": [ { "name": "id", "type": "INT" }, { "name": "name", "type": "STRING" } ],
"dataset": [ [1, "Keystone Securities"], [34, "Vision Securities"] ],
"count": 2,
"timings": { "parse_us": 7, "execute_us": 167 }
}
```
| Field | Meaning |
|---|---|
| `query` | The SQL you sent |
| `results` | One object per statement |
| `results[].statement` | The statement, as KDB understood it |
| `results[].columns` | Name and type of each column |
| `results[].dataset` | The rows. Each row is a list of values in column order. |
| `results[].count` | Number of rows in `dataset` |
| `results[].truncated` | `true` if there were more rows than `limit` |
| `results[].rows_affected` | Rows changed by INSERT, UPDATE or DELETE |
| `results[].message` | For example `3 row(s) inserted` |
| `results[].timings` | Microseconds for parsing and running |
| `columns`, `dataset`, `count` (top level) | A copy of the **last** result, like QuestDB's `/exec`. Left out with `compat=0`. |
Values: numbers are JSON numbers; `TIMESTAMP` and `DATE` are UTC text like `"2026-09-09T05:15:00.000Z"`; `DECIMAL` is text (`"1500.25"`), so it stays exact; `BOOL` is `true`/`false`; NULL is `null`.
### POST runs anything
Send the SQL as the request body. `--data-binary` sends it unchanged.
```powershell
curl.exe -s http://127.0.0.1:9900/exec --data-binary "SELECT symbol, ltp FROM live_data LATEST ON timestamp PARTITION BY symbol LIMIT 2"
```
```json
{"query":"SELECT symbol, ltp FROM live_data LATEST ON timestamp PARTITION BY symbol LIMIT 2","results":[{"statement":"SELECT symbol, ltp FROM live_data LATEST ON timestamp PARTITION BY symbol LIMIT 2","columns":[{"name":"symbol","type":"SYMBOL"},{"name":"ltp","type":"DOUBLE"}],"dataset":[["CITY",527.1],["COAST",401.9]],"count":2,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":35,"execute_us":150}}],"columns":[{"name":"symbol","type":"SYMBOL"},{"name":"ltp","type":"DOUBLE"}],"dataset":[["CITY",527.1],["COAST",401.9]],"count":2,"timings":{"parse_us":35,"execute_us":150}}
```
The body can also be a form: `query=` with `Content-Type: application/x-www-form-urlencoded`.
Many statements in one request, with `compat=0`:
```powershell
curl.exe -s "http://127.0.0.1:9900/exec?compat=0" --data-binary "INSERT INTO brokers VALUES (21, 'Test Broker'); SELECT count(*) AS brokers FROM brokers"
```
```json
{
"query": "INSERT INTO brokers VALUES (21, 'Test Broker'); SELECT count(*) AS brokers FROM brokers",
"results": [
{
"statement": "INSERT INTO brokers VALUES (21, 'Test Broker')",
"columns": [], "dataset": [], "count": 0, "truncated": false,
"rows_affected": 1, "message": "1 row(s) inserted",
"timings": { "parse_us": 13, "execute_us": 3454 }
},
{
"statement": "SELECT count(*) AS brokers FROM brokers",
"columns": [ { "name": "brokers", "type": "LONG" } ],
"dataset": [ [6] ], "count": 1, "truncated": false,
"rows_affected": 0, "message": "",
"timings": { "parse_us": 13, "execute_us": 30 }
}
],
"timings": { "parse_us": 26, "execute_us": 3484 }
}
```
(formatted for reading)
### The GET rule
`GET /exec` only runs statements that **read**: `SELECT`, `SHOW ...`, `EXPLAIN`, `CHECK TABLE`, `USE`. Everything that changes data (`INSERT`, `UPDATE`, `DELETE`, `CREATE`, `DROP`, `ALTER`, `RENAME`, `CREATE VIEW`, `DROP VIEW`, `BACKUP DATABASE`, `CREATE DATABASE`, `DROP DATABASE`) must use **POST**.
Why: a link or an image on any web page can make your browser send a GET request. With this rule, such a page cannot change your data.
```powershell
# GET /exec only reads. SQL that changes data must use POST.
curl.exe -s -w " (HTTP %{http_code})" -G http://127.0.0.1:9900/exec --data-urlencode "query=DELETE FROM brokers WHERE id = 21"
""
# The same SQL with POST works:
curl.exe -s "http://127.0.0.1:9900/exec?compat=0" --data-binary "DELETE FROM brokers WHERE id = 21"
```
```text
{"error":"this SQL changes data, so it must be sent with POST /exec (GET /exec only reads)"} (HTTP 405)
{"query":"DELETE FROM brokers WHERE id = 21","results":[{"statement":"DELETE FROM brokers WHERE id = 21","columns":[],"dataset":[],"count":0,"truncated":false,"rows_affected":1,"message":"1 row(s) deleted","timings":{"parse_us":15,"execute_us":4637}}],"timings":{"parse_us":15,"execute_us":4637}}
```
(`-w "(HTTP %{http_code})"` makes curl print the HTTP status after the body.)
### Errors
A bad statement gets **HTTP 400** and an `error`. When KDB knows the place, `line` and `column` are separate fields:
```powershell
curl.exe -s -w "\nHTTP status: %{http_code}\n" http://127.0.0.1:9900/exec --data-binary "SELECT clsoe FROM ochl_day"
```
```text
{"query":"SELECT clsoe FROM ochl_day","results":[],"saved":0,"rolled_back":false,"error":"line 1, column 8: unknown column 'clsoe' (table 'ochl_day' has: time symbol open high low close volume)","line":1,"column":8}
HTTP status: 400
```
| HTTP status | Meaning |
|---|---|
| 200 | OK |
| 400 | The SQL or the request is wrong (syntax, duplicate key, missing query, ...) |
| 401 | Token missing or wrong |
| 403 | Refused for safety: cross-site request, a host name that is not allowed, missing rights, or a read-only database |
| 404 | No such route, or no such database (`db=`) |
| 409 | The database already exists, or it is in use and cannot be dropped now |
| 405 | GET /exec with SQL that changes data |
| 408 | The request did not arrive within 30 seconds, or the statement was stopped: statement timeout, `DELETE /exec/`, server shutdown |
| 413 | Request body bigger than 64 MB, or the query keeps too much in memory (`--max-result-memory`, `--max-groups`) |
| 429 | Too many failed logins, or the user already runs `--max-queries-per-user` statements |
| 431 | Request headers bigger than 64 KB |
| 500 | Problem on the server: a damaged file, or the database is locked by another program |
| 503 | Busy: a table stayed locked by another transaction too long (`table 'x' is busy`), a `deadlock`, or the server is stopping. Try again a moment later. |
| 507 | The disk is full. Nothing of the request was saved. It works again once space is freed. |
| 304 | Not changed: the `If-None-Match` tag is still the same ([ETag](#format-columns-and-etag)). No body. |
With many statements, `results` holds the statements that ran **before** the error. The statements after the error did not run. Two fields say what was kept:
| Field | Meaning |
|---|---|
| `saved` | How many of the statements in `results` are saved. |
| `rolled_back` | `true` when the error undid an open transaction (or an `atomic=1` request): then nothing of it is saved, `saved` is `0`. |
### atomic=1: all or nothing
Normally each statement of a request is saved on its own. With `atomic=1`, the whole body is **one transaction**: when one statement fails, the ones before it are undone too.
```powershell
# atomic=1: the whole body is one transaction. The second INSERT fails, so the first is undone too.
curl.exe -s -w " (HTTP %{http_code})" "http://127.0.0.1:9900/exec?atomic=1&compat=0" --data-binary "INSERT INTO brokers VALUES (22, 'New Broker'); INSERT INTO brokers VALUES (1, 'Keystone again')"
""
curl.exe -s -G http://127.0.0.1:9900/exec --data-urlencode "query=SELECT count(*) AS brokers FROM brokers WHERE id = 22" --data-urlencode "compat=0"
```
```text
{"query":"INSERT INTO brokers VALUES (22, 'New Broker'); INSERT INTO brokers VALUES (1, 'Keystone again')","results":[{"statement":"INSERT INTO brokers VALUES (22, 'New Broker')","columns":[],"dataset":[],"count":0,"truncated":false,"rows_affected":1,"message":"1 row(s) inserted","timings":{"parse_us":5,"execute_us":31}}],"saved":0,"rolled_back":true,"error":"a row with id='1' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it (the transaction was rolled back)"} (HTTP 400)
{"query":"SELECT count(*) AS brokers FROM brokers WHERE id = 22","results":[{"statement":"SELECT count(*) AS brokers FROM brokers WHERE id = 22","columns":[{"name":"brokers","type":"LONG"}],"dataset":[[0]],"count":1,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":9,"execute_us":63}}],"timings":{"parse_us":9,"execute_us":63}}
```
Broker 22 is not there: `"saved":0,"rolled_back":true`. Statements that cannot run in a transaction (`CREATE`, `DROP`, `ALTER`, `TRUNCATE`, `BACKUP DATABASE`, ...) are refused before anything runs. For a transaction over several requests, use `POST /session` (chapter 15).
### limit and truncated
```powershell
curl.exe -s -G "http://127.0.0.1:9900/exec" --data-urlencode "query=SELECT time, close FROM ochl_minutes WHERE symbol = 'CITY'" --data-urlencode "limit=2" --data-urlencode "compat=0"
```
```json
{"query":"SELECT time, close FROM ochl_minutes WHERE symbol = 'CITY'","results":[{"statement":"SELECT time, close FROM ochl_minutes WHERE symbol = 'CITY'","columns":[{"name":"time","type":"TIMESTAMP"},{"name":"close","type":"DOUBLE"}],"dataset":[["2026-09-09T05:15:00.000Z",512.1],["2026-09-09T05:16:00.000Z",511.9]],"count":2,"truncated":true,"rows_affected":0,"message":"","timings":{"parse_us":46,"execute_us":347}}],"timings":{"parse_us":46,"execute_us":347}}
```
`"truncated":true` tells you that more rows exist. If you need all rows, use a bigger `limit`, `/exp`, or read page by page with `ORDER BY time LIMIT 10000 OFFSET ...`.
### format=columns and ETag
A chart or a program that works column by column can ask for one list per column. It is also shorter, because the column names are not repeated:
```powershell
# format=columns: one list per column instead of one list per row.
curl.exe -s -G http://127.0.0.1:9900/exec --data-urlencode "query=SELECT symbol, close FROM ochl_day WHERE time IN '2026-09-10'" --data-urlencode "format=columns" --data-urlencode "compat=0"
""
# Every answer with rows has an ETag. curl saves it, then sends it back as If-None-Match:
# while the rows are the same, the answer is 304 and has no body.
$url = "http://127.0.0.1:9900/exec?compat=0&query=SELECT%20count(*)%20AS%20brokers%20FROM%20brokers"
curl.exe -s --etag-save etag.txt $url
""
"saved ETag: $(Get-Content etag.txt)"
curl.exe -s -w "(HTTP %{http_code})" --etag-compare etag.txt $url
```
```text
{"query":"SELECT symbol, close FROM ochl_day WHERE time IN '2026-09-10'","results":[{"statement":"SELECT symbol, close FROM ochl_day WHERE time IN '2026-09-10'","columns":[{"name":"symbol","type":"SYMBOL"},{"name":"close","type":"DOUBLE"}],"dataset":{"symbol":["CITY","COAST","VOLT"],"close":[504.9,376.1,191.2]},"count":3,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":15,"execute_us":203}}],"timings":{"parse_us":15,"execute_us":203}}
{"query":"SELECT count(*) AS brokers FROM brokers","results":[{"statement":"SELECT count(*) AS brokers FROM brokers","columns":[{"name":"brokers","type":"LONG"}],"dataset":[[6]],"count":1,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":10,"execute_us":196}}],"timings":{"parse_us":10,"execute_us":196}}
saved ETag: "08452937-5r"
(HTTP 304)
```
- With `format=columns`, `dataset` is an object: `{"symbol": [...], "close": [...]}`. The top-level copy (without `compat=0`) has the same form.
- An `/exec` answer with rows has an **ETag** header: a short tag made from the rows of the last statement. Send it back as `If-None-Match`. While the rows are the same, the answer is **304** with no body, so a dashboard that asks every few seconds does not download the same rows again.
- `curl --etag-save` and `--etag-compare` do this for you. In other programs, keep the `ETag` header and send it back.
- One case has no ETag: `compat=0` in the row format, when the result is not kept in the result cache (`cache=0`, or a query with `now()` or `random()`).
## /exp: download CSV
```powershell
curl.exe -s -G http://127.0.0.1:9900/exp --data-urlencode "query=SELECT time, symbol, close FROM ochl_day WHERE time IN '2026-09-10'" -o closes.csv
Get-Content closes.csv
```
```text
time,symbol,close
2026-09-10T00:00:00.000Z,CITY,504.9
2026-09-10T00:00:00.000Z,COAST,376.1
2026-09-10T00:00:00.000Z,VOLT,191.2
```
- Header line, then rows. Lines end with CRLF. Text with commas or quotes is quoted.
- NULL is an empty field. Times are UTC.
- The answer has `Content-Disposition: attachment; filename="kdb-export.csv"`, so a browser downloads it. Change the name with `&filename=day.csv`.
- `/exp` accepts only reading statements. Anything else:
```powershell
curl.exe -s -w " (HTTP %{http_code})" -G http://127.0.0.1:9900/exp --data-urlencode "query=INSERT INTO brokers VALUES (22, 'Export Test')"
```
```text
{"error":"CSV export needs a SELECT, SHOW or EXPLAIN statement"} (HTTP 400)
```
## /imp: load CSV
`POST /imp` loads a CSV file into a table, the way QuestDB's `/imp` does. Send the file as a form part named `data` (or the CSV itself as the body).
```powershell
curl.exe -s -F "data=@day_closes.csv" "http://127.0.0.1:9900/imp?name=day_closes×tamp=time&partitionBy=YEAR&fmt=json"
```
```text
{"status":"OK","location":"day_closes","rowsRejected":0,"rowsImported":2,"header":true,"partitionBy":"YEAR","timestamp":"time","columns":[{"name":"time","type":"TIMESTAMP","size":8,"errors":0},{"name":"symbol","type":"STRING","size":0,"errors":0},{"name":"close","type":"DOUBLE","size":8,"errors":0},{"name":"volume","type":"LONG","size":8,"errors":0}]}
```
| Parameter | Meaning | Default |
|---|---|---|
| `name` | The table. A new name makes a new table. | the file name without `.csv` |
| `timestamp` | New table: its time column (a `TIMESERIES` table) | none (a relational table) |
| `partitionBy` | New table: `NONE`, `DAY`, `MONTH` or `YEAR` (needs `timestamp`) | `NONE` |
| `overwrite` | `true`: drop the table and make it again from the file | `false` |
| `atomicity` | A value that does not fit its column: `skipCol` makes it NULL, `skipRow` leaves the row out, `abort` loads nothing | `skipCol` |
| `delimiter` | `,`, `;`, `\t` or `|` | found from the first line |
| `forceHeader` | `true`: the first line is a header | found from the first line |
| `fmt` | `json` for the JSON answer above; otherwise a short text answer | text |
| `db` | The database | the default database |
- **Header.** The first line is a header when its values match the table's columns, or when none of them looks like data (a number, `true`/`false`, a time). Columns are matched by name, in any order. Without a header, the values go to the table's columns in order.
- **New tables.** Each column's type comes from its values: `BOOLEAN`, `LONG`, `DOUBLE`, `DATE`, `TIMESTAMP`, else `STRING`. A form part named `schema` may give types: `[{"name":"symbol","type":"SYMBOL"}]`. Making (or dropping, with `overwrite`) a table needs the admin role.
- **Values.** An empty field is NULL; `""` is an empty text. Quoted values may hold commas, quotes (`""`) and line breaks. Times like `2026-09-10T00:00:00.000000Z` work; a long number in a time column is microseconds since 1970 (QuestDB).
- **Keys.** A table with a `DEDUP KEY` keeps the newest row of each key, as QuestDB's `DEDUP UPSERT KEYS` does.
- **All or nothing.** Rows the server refuses (a missing time, a `NOT NULL` column) follow `atomicity` too. What is loaded is one transaction.
- **Speed.** Rows go in the way the line protocol writes them: whole partitions are sorted and written at once.
```powershell
curl.exe -s -w " (HTTP %{http_code})" -F "data=@bad.csv" "http://127.0.0.1:9900/imp?name=day_closes&atomicity=abort&fmt=json"
```
```text
{"status":"line 2, column 'close': 'abc' is not a number (atomicity=abort: nothing was imported)"} (HTTP 400)
```
## POST /write: line protocol
For live feeds and sensors, the **line protocol** of InfluxDB (also used by QuestDB) is the fastest way to load rows. Each line is one row: `table,tag=text field=value,field=value time`.
```powershell
# Two new live prices in the line format: table,tag=text field=value,... time
$lines = "live_data,symbol=CITY ltp=528.5,ltv=120i,volume=45120i 1789031700`nlive_data,symbol=VOLT ltp=192.4,ltv=80i,volume=21000i 1789031700"
curl.exe -s -w "(HTTP %{http_code})" "http://127.0.0.1:9900/write?precision=s" --data-binary $lines
""
curl.exe -s -G http://127.0.0.1:9900/exec --data-urlencode "query=SELECT timestamp, symbol, ltp, ltv, volume FROM live_data WHERE timestamp = '2026-09-10T15:00:00+05:45'" --data-urlencode "compat=0"
```
```text
(HTTP 204)
{"query":"SELECT timestamp, symbol, ltp, ltv, volume FROM live_data WHERE timestamp = '2026-09-10T15:00:00+05:45'","results":[{"statement":"SELECT timestamp, symbol, ltp, ltv, volume FROM live_data WHERE timestamp = '2026-09-10T15:00:00+05:45'","columns":[{"name":"timestamp","type":"TIMESTAMP"},{"name":"symbol","type":"SYMBOL"},{"name":"ltp","type":"DOUBLE"},{"name":"ltv","type":"LONG"},{"name":"volume","type":"LONG"}],"dataset":[["2026-09-10T09:15:00.000Z","CITY",528.5,120,45120],["2026-09-10T09:15:00.000Z","VOLT",192.4,80,21000]],"count":2,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":12,"execute_us":58}}],"timings":{"parse_us":12,"execute_us":58}}
```
- A good request answers **204** and has no body. All lines of one request are one transaction: all or nothing.
- The table must exist. Tags (before the first space) fill `SYMBOL` or `STRING` columns. Whole numbers need an `i` at the end (`120i`).
- The time at the end is in nanoseconds, unless you say `precision=s`, `ms` or `u` (microseconds). Without `precision=`, times that look like seconds or milliseconds are refused with 400, so wrong dates do not get in. Always send `precision=` when your times are not nanoseconds.
- The help topic **Line protocol** has every rule.
## GET /api/info
Everything the console's sidebar shows.
```powershell
$info = Invoke-RestMethod http://127.0.0.1:9900/api/info
"version: $($info.version) log LSN: $($info.last_lsn) damaged files: $($info.damaged.Count)"
$info.tables | Select-Object name, kind, partition, timestamp_column, rows | Format-Table -AutoSize
```
```text
version: 0.2.0-dev log LSN: 32 damaged files: 0
name kind partition timestamp_column rows
---- ---- --------- ---------------- ----
brokers relational NONE 6
trade_log timeseries DAY date 32
live_data timeseries DAY timestamp 12
ochl_day timeseries YEAR time 57
ochl_minutes timeseries MONTH time 956
securities relational NONE 5
```
The JSON has these fields (the start of a real answer, cut):
```json
{"dir":"market","version":"0.2.0-dev","opened_ms":1789113512561,"server_time_ms":1789113526709,"last_lsn":1098,"data_files":10,"data_bytes":75739,"tables":[{"name":"brokers","kind":"relational","partition":"NONE","timestamp_column":"","rows":7,"bytes":336,"columns":[{"name":"id","type":"INT","primary_key":true,"not_null":false,"computed":false,"references":""},{"name":"name","type":"STRING","primary_key":false,"not_null":true,"computed":false,"references":""}],"create_sql":"CREATE TABLE brokers (\n id INT PRIMARY KEY,\n name STRING NOT NULL\n)"}, ...
```
| Field | Meaning |
|---|---|
| `dir`, `version` | Database folder and KDB version |
| `opened_ms`, `server_time_ms` | When the database was opened; the server clock (ms since 1970, UTC) |
| `last_lsn` | Number of the last change in the log |
| `data_files`, `data_bytes` | Count and size of the data files |
| `tables[]` | `name`, `kind`, `partition`, `timestamp_column`, `rows`, `bytes`, `columns[]`, `create_sql` |
| `damaged[]` | Damaged files (empty when all is OK) |
| `recovery_summary` | What happened when the database was opened |
| `backup_dir`, `last_backup` | Backup folder and the newest backup (or `null`) |
| `auth_required` | Token mode |
| `read_only` | The database (or the whole server) takes no changes |
| `protection` | The server's `statement_timeout_ms`, `max_result_bytes`, `max_groups`, `max_queries_per_user`, `safe_updates`, `running` statements, `server_read_only`, `audit` |
| `backup_schedule` | With `--backup-schedule`: `schedule`, `copy_to`, `next_run_ms`, `last_run_ms`, `last_run_ok`, `last_ok_ms`, `running`, `warning`, and `databases[]` of the last run (`ok`, `error`, `dir`, `files`, `bytes`, `copy_ok`, `copy_error`, ...) |
## POST /api/check
```powershell
curl.exe -s -X POST http://127.0.0.1:9900/api/check
```
```json
{"ok":true,"files_checked":11,"bytes_checked":76566,"wal_segments_checked":1,"problems":[],"elapsed_us":11880}
```
`ok` is `false` when there is damage; `problems` then lists it. For big databases this reads every file, so it can take time.
## POST /api/backup and GET /api/backups
```powershell
curl.exe -s -X POST http://127.0.0.1:9900/api/backup
```
```json
{"name":"20260923-120933-857","dir":"backups/20260923-120933-857","created_ms":1790165373857,"created":"2026-09-23T12:09:33.857Z","end_lsn":32,"checkpoint_lsn":32,"files":9,"bytes":194672}
```
```powershell
curl.exe -s http://127.0.0.1:9900/api/backups
```
```json
{"backups":[{"name":"20260923-120933-857","dir":"backups/20260923-120933-857","created_ms":1790165373857,"created":"2026-09-23T12:09:33.857Z","end_lsn":32,"checkpoint_lsn":32,"files":9,"bytes":194672}]}
```
- Without `--backups`, `/api/backup` answers HTTP 400 `no backup folder is set; start KDB with a backup folder (for example --backups D:/kdb_backups)`.
- After each backup the server removes old backups: it keeps the newest of each of the last 7 days and 4 weeks.
- `BACKUP DATABASE` (as SQL, with POST) does the same:
```powershell
curl.exe -s "http://127.0.0.1:9900/exec?compat=0" --data-binary "BACKUP DATABASE"
```
```json
{"query":"BACKUP DATABASE","results":[{"statement":"BACKUP DATABASE","columns":[{"name":"backup","type":"STRING"},{"name":"files","type":"LONG"},{"name":"bytes","type":"LONG"},{"name":"log_lsn","type":"LONG"}],"dataset":[["backups/20260923-120934-350",9,194672,32]],"count":1,"truncated":false,"rows_affected":0,"message":"backup created and verified","timings":{"parse_us":8,"execute_us":51416}}],"timings":{"parse_us":8,"execute_us":51416}}
```
A daily backup script for a running server (see chapter 7):
```powershell
# daily-backup-server.ps1
$r = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/api/backup"
"backup $($r.name): $($r.files) files, $($r.bytes) bytes"
$c = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/api/check"
if (-not $c.ok) { Write-Output "CHECK FOUND A PROBLEM: $($c.problems -join '; ')"; exit 2 }
"check ok: $($c.files_checked) files"
```
```text
backup 20260923-121039-826: 9 files, 195468 bytes
check ok: 14 files
```
## Many databases
`kdb serve` without a folder serves **every database in the data folder** (chapter 1). Each request chooses its database:
- the parameter `db=` (in the URL, or as a form field), or
- the header `X-KDB-Database: ` (handy when a program talks to one database all the time).
`db=` wins when both are given. Without either, the default database is used (`--db `, else `default`, which is created when first used). Names ignore case.
This example starts a second server on port 9946 for the data folder `kdb-data`:
```powershell
# A server for a whole data folder: every database in it, chosen per request.
# --pg-port 0: the first server already has the Postgres port 8832.
$server = Start-Process kdb -ArgumentList "serve", "--data-dir", "kdb-data", "--port", "9946", "--pg-port", "0" -PassThru -WindowStyle Hidden
Start-Sleep -Seconds 1
$u = "http://127.0.0.1:9946"
# Create two databases.
Invoke-RestMethod -Method Post "$u/api/databases?name=market" | ConvertTo-Json -Compress
Invoke-RestMethod -Method Post "$u/api/databases" -ContentType "application/json" -Body '{"name":"demo"}' | ConvertTo-Json -Compress
# The same table name in both, with different rows: db= or the X-KDB-Database header picks the database.
$sql = "CREATE TABLE prices (symbol SYMBOL PRIMARY KEY, close DOUBLE)"
$null = Invoke-RestMethod -Method Post "$u/exec?db=market" -ContentType "text/plain" -Body "$sql; INSERT INTO prices VALUES ('CITY', 527.1), ('COAST', 401.9)"
$null = Invoke-RestMethod -Method Post "$u/exec" -Headers @{ "X-KDB-Database" = "demo" } -ContentType "text/plain" -Body "$sql; INSERT INTO prices VALUES ('TEST', 1)"
curl.exe -s "$u/exp?db=market&query=SELECT%20*%20FROM%20prices"
curl.exe -s "$u/exp?db=demo&query=SELECT%20*%20FROM%20prices"
# A query in market can read a table of demo: database.table
curl.exe -s -G "$u/exp" --data-urlencode "db=market" --data-urlencode "query=SELECT symbol, close FROM prices UNION ALL SELECT symbol, close FROM demo.prices"
# Every database, with its table count.
(Invoke-RestMethod "$u/api/databases").databases | Select-Object name, tables, default | Format-Table -AutoSize
# A database that does not exist.
curl.exe -s -w " (HTTP %{http_code})" "$u/exec?db=nope&query=SELECT%201"
""
# Drop one: its folder moves to kdb-data\_trash.
(Invoke-RestMethod -Method Delete "$u/api/databases/demo").dropped
"demo folder: $(Test-Path kdb-data\demo) folders in _trash: $((Get-ChildItem kdb-data\_trash).Count)"
Stop-Process -Id $server.Id
```
```text
{"name":"market","created":true}
{"name":"demo","created":true}
symbol,close
CITY,527.1
COAST,401.9
symbol,close
TEST,1
symbol,close
CITY,527.1
COAST,401.9
TEST,1
name tables default
---- ------ -------
demo 1 False
market 1 False
{"error":"database 'nope' does not exist; create it with CREATE DATABASE nope"} (HTTP 404)
True
demo folder: False folders in _trash: 1
```
A query can also **read** a table of another database on the same server: write `database.table`, like `demo.prices` above. It works in `SELECT`, `JOIN`, subqueries and `INSERT ... SELECT`. A statement only **changes** tables of its own database. The user needs the right to read the other database. The help topic **Queries across databases** has more.
### GET /api/databases
```json
{"databases":[{"name":"demo","dir":"kdb-data/demo","default":false,"open":true,"tables":1,"bytes":235},{"name":"market","dir":"kdb-data/market","default":false,"open":true,"tables":1,"bytes":235}],"default":"default","data_dir":"kdb-data","can_create":true}
```
| Field | Meaning |
|---|---|
| `databases[]` | Every database, sorted by name: `name`, `dir` (its folder), `default`, `open`, `tables`, `bytes` (data files). When a database cannot be opened (for example another program uses it), `tables` is `null` and `error` says why. |
| `default` | The database used when a request has no `db=` |
| `data_dir` | The data folder (empty for `kdb serve `) |
| `can_create` | `false` for `kdb serve `: that server has one database and cannot create or drop others |
Listing opens every database, so the list can count its tables.
### POST /api/databases and DELETE /api/databases/<name>
| Request | Answer |
|---|---|
| `POST /api/databases?name=market` (or form `name=market`, or JSON `{"name":"market"}`) | `200 {"name":"market","created":true}`; `409` if it exists; `400` for a bad name |
| `DELETE /api/databases/demo` | `200 {"name":"demo","dropped":true,"moved_to":"kdb-data/_trash/1789136015339-demo"}`; `404` if it does not exist; `409` while a request uses it |
| `GET /api/databases/demo` | `405`: changes need POST or DELETE |
A dropped database is **not deleted**. Its folder moves to `/_trash/-`. To get it back, stop the server and move the folder back into the data folder.
### Database statements in SQL
`/exec` also runs `CREATE DATABASE`, `DROP DATABASE`, `SHOW DATABASES` and `USE` (chapter 11). `CREATE` and `DROP DATABASE` need POST. `USE name` changes the database only for the **rest of that request**:
```powershell
curl.exe -s "http://127.0.0.1:9946/exec?compat=0" --data-binary "USE market; SELECT count(*) FROM prices"
```
```json
{"query":"USE market; SELECT count(*) FROM prices","results":[{"statement":"USE market","columns":[],"dataset":[],"count":0,"truncated":false,"rows_affected":0,"message":"using database 'market'","timings":{"parse_us":0,"execute_us":8}},{"statement":"SELECT count(*) FROM prices","columns":[{"name":"count(*)","type":"LONG"}],"dataset":[[2]],"count":1,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":22,"execute_us":91}}],"timings":{"parse_us":22,"execute_us":99}}
```
The next request uses `db=` (or the default database) again.
## Cursors: read a big result bit by bit
`/exec` returns at most 1,000,000 rows at once, and a big result is a big reply. A **cursor** runs the SELECT once, keeps the rows on the server, and gives them to you a few at a time.
| Request | Answer |
|---|---|
| `POST /cursor` with one SELECT as the body | `{"cursor":"","columns":[...],"rows":N}` |
| `GET /cursor/?fetch=N` | the next N rows (default 1,000): `{"cursor","columns","dataset","count","done"}`. `done` is true when no rows are left. |
| `DELETE /cursor/` | `{"cursor":"","closed":true}` |
```powershell
# A cursor: the server runs the SELECT once and keeps the rows; you read them bit by bit.
$c = Invoke-RestMethod -Method Post "http://127.0.0.1:9900/cursor" -ContentType "text/plain" `
-Body "SELECT time, symbol, close FROM ochl_minutes WHERE time IN '2026-09-10' ORDER BY time, symbol"
"rows kept: $($c.rows) columns: $(($c.columns | ForEach-Object { $_.name }) -join ', ')"
# GET only reads the kept rows: 3 at a time.
foreach ($i in 1..2) {
$page = Invoke-RestMethod "http://127.0.0.1:9900/cursor/$($c.cursor)?fetch=3"
$page.dataset | ForEach-Object { $_ -join ' | ' }
"count: $($page.count) done: $($page.done)"
}
# Close it. A closed (or unknown) cursor answers 404.
(Invoke-RestMethod -Method Delete "http://127.0.0.1:9900/cursor/$($c.cursor)").closed
curl.exe -s -o NUL -w "HTTP %{http_code}" "http://127.0.0.1:9900/cursor/$($c.cursor)?fetch=3"
""
```
```text
rows kept: 476 columns: time, symbol, close
2026-09-10T05:15:00.000Z | CITY | 504.4
2026-09-10T05:15:00.000Z | COAST | 380
2026-09-10T05:16:00.000Z | CITY | 504.6
count: 3 done: False
2026-09-10T05:16:00.000Z | COAST | 379.6
2026-09-10T05:17:00.000Z | CITY | 504.2
2026-09-10T05:17:00.000Z | COAST | 380.1
count: 3 done: False
True
HTTP 404
```
- The rows are a snapshot: changes after `POST /cursor` do not change them. They stay in the server's memory until the cursor is closed.
- A cursor closes by itself after 10 minutes without a `GET`. At most 16 cursors per database are open at the same time (SQL cursors from chapter 13 count too). At most 10 million rows per cursor.
- `GET /cursor/` only reads (the rule above: GET never changes data). Opening needs POST, closing needs DELETE.
- Use the same `db=` for all three requests: a cursor belongs to one database.
## Clients
Every client below does the same three things: POST the SQL to `/exec?compat=0`, read `results`, and turn HTTP errors into error messages. The files are in `examples/08-http-api/`.
### PowerShell
```powershell
$sql = "SELECT symbol, sum(quantity) AS shares FROM trade_log WHERE date IN '2026-09-10' GROUP BY symbol ORDER BY shares DESC"
$r = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/exec?compat=0" -Body $sql -ContentType "text/plain; charset=utf-8"
$result = $r.results[0]
$names = $result.columns | ForEach-Object { $_.name }
"columns: $($names -join ', ')"
foreach ($row in $result.dataset) { " $($row -join ' | ')" }
```
```text
columns: symbol, shares
COAST | 2850
CITY | 910
TELCO | 770
VOLT | 400
```
To send a `.sql` file with non-English text, send the **bytes** of the file, so no encoding is lost:
```powershell
# Send a whole .sql file in one request.
@"
INSERT OR REPLACE INTO ochl_day VALUES ('2026-09-14', 'COAST', 378.5, 381.0, 377.2, 380.4, 99000);
INSERT OR REPLACE INTO ochl_day VALUES ('2026-09-14', 'VOLT', 193.7, 196.0, 192.8, 195.1, 70500);
"@ | Set-Content load.sql -Encoding UTF8
$body = [System.IO.File]::ReadAllBytes((Resolve-Path load.sql))
$r = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/exec?compat=0" -Body $body -ContentType "text/plain; charset=utf-8"
$r.results | ForEach-Object { $_.message }
```
```text
1 row(s) inserted
1 row(s) inserted
```
### Python (standard library only)
`client.py`:
```python
# client.py - query KDB from Python. Only the standard library is used.
import json
import os
import urllib.error
import urllib.parse
import urllib.request
KDB = os.environ.get("KDB_URL", "http://127.0.0.1:9900")
TOKEN = "" # set this if the server was started with --token
def kdb(sql, limit=10000):
url = KDB + "/exec?" + urllib.parse.urlencode({"limit": limit, "compat": 0})
req = urllib.request.Request(url, data=sql.encode("utf-8"), method="POST")
req.add_header("Content-Type", "text/plain; charset=utf-8")
if TOKEN:
req.add_header("Authorization", "Bearer " + TOKEN)
try:
with urllib.request.urlopen(req) as resp:
return json.load(resp)["results"]
except urllib.error.HTTPError as e:
body = json.load(e)
raise RuntimeError(f"KDB error (HTTP {e.code}): {body['error']}") from None
# Insert a row (INSERT OR REPLACE: safe to run again).
print(kdb("INSERT OR REPLACE INTO ochl_day VALUES "
"('2026-09-13', 'CITY', 505, 509, 503.1, 508.2, 91000)")[0]["message"])
# Read rows as dictionaries.
res = kdb("SELECT time, symbol, close FROM ochl_day WHERE symbol = 'CITY' AND time >= '2026-09-09'")[0]
names = [c["name"] for c in res["columns"]]
for row in res["dataset"]:
print(dict(zip(names, row)))
# Errors become exceptions.
try:
kdb("SELECT clsoe FROM ochl_day")
except RuntimeError as err:
print(err)
```
```powershell
python client.py
```
```text
1 row(s) inserted
{'time': '2026-09-09T00:00:00.000Z', 'symbol': 'CITY', 'close': 504.6}
{'time': '2026-09-10T00:00:00.000Z', 'symbol': 'CITY', 'close': 504.9}
{'time': '2026-09-13T00:00:00.000Z', 'symbol': 'CITY', 'close': 508.2}
KDB error (HTTP 400): line 1, column 8: unknown column 'clsoe' (table 'ochl_day' has: time symbol open high low close volume)
```
Tested with Python 3.15. To turn a time text into a Python `datetime`: `datetime.fromisoformat(t.replace("Z", "+00:00"))`.
### Node.js (18 or newer)
`client.mjs`:
```javascript
// client.mjs - query KDB from Node.js 18 or newer (fetch is built in).
const KDB = process.env.KDB_URL || "http://127.0.0.1:9900";
const TOKEN = ""; // set this if the server was started with --token
async function kdb(sql, limit = 10000) {
const res = await fetch(`${KDB}/exec?limit=${limit}&compat=0`, {
method: "POST",
headers: {
"Content-Type": "text/plain; charset=utf-8",
...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
},
body: sql,
});
const body = await res.json();
if (!res.ok) throw new Error(`KDB error (HTTP ${res.status}): ${body.error}`);
return body.results;
}
const [res] = await kdb(
"SELECT symbol, ltp, pct_change FROM live_data LATEST ON timestamp PARTITION BY symbol ORDER BY pct_change DESC"
);
const names = res.columns.map((c) => c.name);
for (const row of res.dataset) {
console.log(Object.fromEntries(names.map((n, i) => [n, row[i]])));
}
try {
await kdb("INSERT INTO brokers VALUES (58, 'Duplicate')");
} catch (err) {
console.log(err.message);
}
```
```powershell
node client.mjs
```
```text
{ symbol: 'CITY', ltp: 527.1, pct_change: 2.71 }
{ symbol: 'TELCO', ltp: 897.7, pct_change: 1.9 }
{ symbol: 'COAST', ltp: 401.9, pct_change: 0.7 }
{ symbol: 'VOLT', ltp: 184, pct_change: -0.86 }
KDB error (HTTP 400): a row with id='58' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it
```
Tested with Node 24.
### Go (standard library only)
`client.go`:
```go
// client.go - query KDB from Go (standard library only).
// Run: go run client.go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
)
var kdbURL = "http://127.0.0.1:9900"
const token = "" // set this if the server was started with --token
type column struct {
Name string `json:"name"`
Type string `json:"type"`
}
type result struct {
Statement string `json:"statement"`
Columns []column `json:"columns"`
Dataset [][]interface{} `json:"dataset"`
Count int `json:"count"`
RowsAffected int `json:"rows_affected"`
Message string `json:"message"`
}
type response struct {
Results []result `json:"results"`
Error string `json:"error"`
Line int `json:"line"`
Column int `json:"column"`
}
func query(sql string) ([]result, error) {
u := kdbURL + "/exec?" + url.Values{"compat": {"0"}, "limit": {"10000"}}.Encode()
req, err := http.NewRequest("POST", u, strings.NewReader(sql))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var r response
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("KDB error (HTTP %d): %s", resp.StatusCode, r.Error)
}
return r.Results, nil
}
func main() {
if u := os.Getenv("KDB_URL"); u != "" {
kdbURL = u
}
res, err := query("SELECT time, first(open) AS open, last(close) AS close FROM ochl_minutes " +
"WHERE symbol = 'CITY' AND time IN '2026-09-10' SAMPLE BY 1h TIME ZONE 'Asia/Kathmandu'")
if err != nil {
panic(err)
}
for _, row := range res[0].Dataset {
// Times are strings like "2026-09-10T05:15:00.000Z"; numbers are float64.
fmt.Printf("%v open=%v close=%v\n", row[0], row[1], row[2])
}
if _, err := query("DELETE FROM brokers WHERE id = 58"); err != nil {
fmt.Println(err)
}
}
```
```powershell
go run client.go
```
```text
2026-09-10T05:15:00.000Z open=504.6 close=505.3
2026-09-10T06:15:00.000Z open=505.3 close=502.7
2026-09-10T07:15:00.000Z open=502.7 close=502.6
2026-09-10T08:15:00.000Z open=502.6 close=504.9
KDB error (HTTP 400): cannot remove brokers(id) = '58': table 'trade_log' column 'buyer' still refers to it (foreign key 'trade_log_buyer_fkey')
```
Tested with Go 1.26. Parse times with `time.Parse(time.RFC3339Nano, s)`. A Go app can also use its PostgreSQL driver `pgx` on port 8832 (chapter 14). This HTTP client needs no driver at all.
### C# (.NET 6 or newer)
`Client.cs` (**not run** while writing this tutorial: there was no .NET SDK on the test PC):
```csharp
// Client.cs - query KDB from C# (.NET 6 or newer).
// NOT RUN while writing the tutorial (no .NET SDK on that computer).
// Use it in a console project: dotnet new console, then replace Program.cs.
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
class KdbClient
{
static readonly HttpClient Http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:9900") };
const string Token = ""; // set this if the server was started with --token
static async Task Query(string sql)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/exec?compat=0&limit=10000");
request.Content = new StringContent(sql, Encoding.UTF8, "text/plain");
if (Token != "") request.Headers.Add("Authorization", "Bearer " + Token);
using var response = await Http.SendAsync(request);
var text = await response.Content.ReadAsStringAsync();
var json = JsonDocument.Parse(text).RootElement.Clone();
if (!response.IsSuccessStatusCode)
throw new Exception($"KDB error (HTTP {(int)response.StatusCode}): {json.GetProperty("error").GetString()}");
return json.GetProperty("results");
}
static async Task Main()
{
var results = await Query("SELECT symbol, close FROM ochl_day WHERE time IN '2026-09-10'");
foreach (var row in results[0].GetProperty("dataset").EnumerateArray())
Console.WriteLine($"{row[0].GetString()} {row[1].GetDouble()}");
}
}
```
## Access token
A second server on another port, with a token. Every `kdb serve` also opens the PostgreSQL port 8832 (chapter 14), and only one server can have it, so a second server needs `--pg-port 0` (off) or another port:
```powershell
kdb serve tokendb --port 9944 --pg-port 0 --token change-me-please
```
Then:
```powershell
curl.exe -s http://127.0.0.1:9944/health
""
curl.exe -s -w " (HTTP %{http_code})" "http://127.0.0.1:9944/exec?query=SELECT%201"
""
curl.exe -s -w " (HTTP %{http_code})" -H "Authorization: Bearer wrong" "http://127.0.0.1:9944/exec?query=SELECT%201"
""
curl.exe -s -H "Authorization: Bearer change-me-please" "http://127.0.0.1:9944/exec?query=SELECT%201%20AS%20one&compat=0"
""
curl.exe -s --cookie "kdb_token=change-me-please" "http://127.0.0.1:9944/exp?query=SELECT%201%20AS%20one"
```
```text
{"status":"ok","version":"0.2.0-dev","server_time_ms":1789114675621,"auth_required":true,"login":false}
{"error":"access token required"} (HTTP 401)
{"error":"wrong access token"} (HTTP 401)
{"query":"SELECT 1 AS one","results":[{"statement":"SELECT 1 AS one","columns":[{"name":"one","type":"LONG"}],"dataset":[[1]],"count":1,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":26,"execute_us":16}}],"timings":{"parse_us":26,"execute_us":16}}
one
1
```
- Send the token as `Authorization: Bearer ` (programs) or as the cookie `kdb_token=` (the console does this).
- `/health` works without a token.
- The 401 answer has the header `WWW-Authenticate: Bearer realm="kdb"`.
- The token can do everything. For programs that should only read, or only write one database, use **users** instead.
## Stop a slow request
`kdb serve` stops every statement after `--statement-timeout` (60 s by default) with HTTP 408. Send your own shorter time, or give the request an id so that you can stop it:
| Header | Does |
|---|---|
| `X-KDB-Statement-Timeout: 5s` | a shorter time for this request (admins may also make it longer) |
| `X-KDB-Request-Id: report-17` | `DELETE /exec/report-17` stops it (your own requests; admins: all) |
| `X-KDB-Safe-Updates: on` | refuse `UPDATE` and `DELETE` without `WHERE` (`off` when the server has `--safe-updates`) |
```text
{"query":"SELECT ...","results":[],"error":"canceling statement due to user request"} (HTTP 408)
```
Closing the connection also stops the statement. Nothing of a stopped change is saved. Chapter 21 has the details.
## Users and logins
When the data folder has users (`CREATE USER`, or `kdb user add`), every request needs a login. Chapter 16 explains users and roles; here is only the HTTP part.
| How | Send | For |
|---|---|---|
| HTTP Basic | `curl -u name:password ...` (header `Authorization: Basic ...`) | scripts; KDB remembers a good password for 10 minutes, so it is not slow |
| Log in once | `POST /api/login` with `{"user":"...","password":"..."}` (JSON or form; users with two-factor login add `"code":"123456"`, chapter 21). The answer has `token` and sets the cookie `kdb_login` (HttpOnly, SameSite=Strict, 12 hours). Then send `Authorization: Bearer `. | apps, the web console |
| Log out | `POST /api/logout` with the cookie or the Bearer token | |
| The `--token` | `Authorization: Bearer ` | everything, as before |
| Answer | Means |
|---|---|
| 401 `{"error":"login required: ...","login":true}` | no login, or a wrong password. The header is `WWW-Authenticate: Basic realm="kdb"` (the console gets `Bearer`, so the browser shows no password box). |
| 403 `{"error":"INSERT needs role write on database 'shop'; user 'report' has read"}` | logged in, but the role is too weak |
| 429 `{"error":"too many failed logins; try again in 4 s"}` and `Retry-After` | 5 failed logins for one user from one address: wait 1 s, 2 s, 4 s, ... up to 15 minutes |
Rights per route: `read` for `/exec` and `/exp` with reading SQL, `/api/info`, `/api/backups` and cursors; `write` for changing SQL and `/write`; `admin` on the database for schema changes, `/api/check` and `/api/backup`; ROLE `admin` for `POST /api/databases`, `DELETE /api/databases/` and user statements. `GET /api/databases` lists only the databases the user may read. A transaction session (`POST /session`) can only be used by the user who opened it.
## Safety rules of the API
The server refuses requests that a web page in your browser could send behind your back:
```powershell
curl.exe -s -w " (HTTP %{http_code})" -H "Sec-Fetch-Site: cross-site" "http://127.0.0.1:9900/exec?query=SELECT%201"
""
curl.exe -s -w " (HTTP %{http_code})" -H "Origin: http://example.com" "http://127.0.0.1:9900/exec?query=SELECT%201"
""
curl.exe -s -w " (HTTP %{http_code})" -H "Host: market.example.com" "http://127.0.0.1:9900/exec?query=SELECT%201"
```
```text
{"error":"cross-site requests are not allowed"} (HTTP 403)
{"error":"cross-origin requests are not allowed"} (HTTP 403)
{"error":"host name 'market.example.com' is not allowed without an access token; use localhost or start the server with --token"} (HTTP 403)
```
| Rule | Why |
|---|---|
| GET /exec and /exp only read | Links and images can send GET requests |
| Browser requests from other sites get 403 | A bad web page must not use your local KDB |
| Without a token, the `Host` must be `localhost` or `127.x.x.x` | Stops DNS rebinding. A reverse proxy needs `--token` or users. |
| `kdb serve --bind 0.0.0.0` without `--token` and without users does not start | Nobody can open all data to the network by mistake |
| Login cookies are `HttpOnly` and `SameSite=Strict`; `POST /api/login` from another site gets 403 | Scripts and other sites cannot steal or use a login |
| Failed logins are logged and slowed down | Guessing passwords does not work |
| The token is compared in constant time | Guessing it by timing does not work |
Programs like curl, Python, Go and Node do not send `Sec-Fetch-Site` or `Origin`, so they are not affected. JavaScript in a web page on **another** site cannot call KDB (there are no CORS headers). Call KDB from your backend (for example the 006 Go app), not from browser code.
## Limits
| Limit | Value |
|---|---|
| Rows per statement (`limit`) | default 10,000 for /exec, 1,000,000 for /exp; at most 1,000,000 |
| Request body | 64 MB |
| Request headers | 64 KB |
| Time to send a request | 30 seconds |
| Idle keep-alive connection | closed after 30 seconds |
| Worker threads | 8: at most 8 requests run at once, the next ones wait for a free worker |
| Open cursors (`/cursor`) | 16 per database; unused ones close after 10 minutes |
| Open sessions (`/session`) | 1,000; unused ones close after 10 minutes |
| Writers at the same time | one per table; writers of different tables run together |
| Readers at the same time | many; a read never waits for another read |
| Time of one statement | `--statement-timeout` (60 s) |
| Statements of one user at the same time | `--max-queries-per-user` (32) |
Reads see the last committed rows. They do not wait for an open transaction, only for a statement that is running right now (usually a millisecond; a long `CALL` or trigger can take longer, see `--reader-yield` in chapter 15). A writer waits only for another writer of the **same** table: at most 5 seconds, then HTTP 503 `table 'x' is busy` (chapter 15).
## Not available yet
| Feature | QuestDB has it | KDB |
|---|---|---|
| ILP over its own TCP port | yes (port 9009) | no. Send the same lines over HTTP: [POST /write](#post-write-line-protocol). |
| HTTPS | no (use a proxy) | no (use a proxy) |
The PostgreSQL protocol is there: port 8832, for `psql`, DBeaver, Go `pgx` and Python `psycopg` (chapter 14).
## Try it yourself
1. With curl, get the newest live price of TELCO as JSON, without the QuestDB copy.
2. Download all COAST daily candles as `coast.csv`.
3. In Python, insert a stock index snapshot row and read it back. (Create the table first with POST.)
Answers
1. ```powershell
curl.exe -s -G http://127.0.0.1:9900/exec --data-urlencode "query=SELECT symbol, ltp, timestamp FROM live_data WHERE symbol = 'TELCO' LATEST ON timestamp PARTITION BY symbol" --data-urlencode "compat=0"
```
2. ```powershell
curl.exe -s -G http://127.0.0.1:9900/exp --data-urlencode "query=SELECT * FROM ochl_day WHERE symbol = 'COAST' ORDER BY time" -o coast.csv
```
3. With `kdb()` from `client.py`:
```python
kdb("CREATE TABLE IF NOT EXISTS index_live (time TIMESTAMP, name SYMBOL, value DOUBLE) "
"TIMESERIES(time) PARTITION BY DAY DEDUP KEY (name, time)")
kdb("INSERT OR REPLACE INTO index_live VALUES ('2026-09-10T15:00:00+05:45', 'MARKET', 2650.5)")
print(kdb("SELECT * FROM index_live")[0]["dataset"])
```
It prints `[['2026-09-10T09:15:00.000Z', 'MARKET', 2650.5]]`.
---
Next: [9. Backup and safety](09-backup-and-safety.md)
---
# 9. Backup and safety
- Category: Tutorial
## What you will learn
- What protects your data, in simple words.
- How to check a database, make and test backups, and restore to an exact time.
- What to do when a disk is damaged: a real example with real output.
The examples use the database `market` with the sample data. **No server may be running** on it (these commands need the database closed). Chapter 8 shows the server versions (`/api/check`, `/api/backup`).
## What protects your data
| Danger | What KDB does |
|---|---|
| Power cut or crash while writing | Every change goes into the **log** first, and the log is saved to disk before KDB says "done". At the next start KDB plays the log again. Nothing that was confirmed is lost. |
| A file only half written | Data files are **never changed**. KDB writes a new version, checks it, and then switches to it. A crash leaves the old version complete. |
| A bit on the disk goes bad | Every block of every file has a **checksum**. KDB notices one wrong bit and knows which file and block is bad. It never returns wrong data without telling you. |
| A damaged file | **Repair ladder:** KDB tries the previous version of the file (+ the log), then a copy from a backup (+ the log). |
| The list of files is damaged | The list (manifest) is saved **twice**. One good copy is always left. |
| A mistake (wrong UPDATE, bad import) | **Point-in-time restore:** a backup + the log, up to the second before the mistake. |
| The whole disk dies | Only **backups on another disk** help. `kdb serve --backup-schedule "daily 16:00" --backup-copy ` makes them by itself (chapter 21). |
| `DROP TABLE` by mistake | The table goes to the **trash** for 7 days: `UNDROP TABLE t` (chapter 21). |
| A slow query or a script in a loop | Statement timeout, memory limits and a limit per user in `kdb serve` (chapter 21). |
The details are in docs/FORMAT.md (for developers).
## The commands
| Command | What it does | Changes the database? |
|---|---|---|
| `kdb info ` | Lists the files, versions and log position | no |
| `kdb check ` | Reads every block and every log record; reports damage | no |
| `kdb backup ` | Makes a backup and checks it | no (it adds a backup) |
| `kdb backups ` | Lists backups, newest first | no |
| `kdb verify-backup ` | Reads the whole backup again | no |
| `kdb repair [--backups ] [--accept-log-loss]` | Fixes damaged files | yes |
| `kdb restore [--logs-from ] [--until "YYYY-MM-DD HH:MM[:SS]"]` | Makes a **new** database from a backup | no (makes a new folder) |
| `kdb dump-file ` | Header and block checksums of one file | no |
Exit codes: `0` OK, `1` error (for example: the database is open in another program), `2` damage, `3` the database needs a newer KDB (chapter 24).
## kdb info
```powershell
kdb info market
```
```text
database market
manifest copy b, sequence 12
last shutdown clean
log position 30 (saved in files up to 30)
newest change 2026-09-23 17:57:58
features batched_log_sync, block_stats, partition_format_2
manifest keys 23
FILE VERSION SIZE LSN STATUS
catalog 6 2.1 KB 30 ok
t/1/all 7 777 B 30 ok
t/2/all 8 556 B 30 ok
t/3/2025 9 1.7 KB 30 ok
t/3/2026 10 3.6 KB 30 ok
t/4/2026-09 11 57.0 KB 30 ok
t/5/2026-09-09 12 1.4 KB 30 ok
t/5/2026-09-10 13 2.4 KB 30 ok
t/6/2026-09-10 14 2.7 KB 30 ok
9 files, 72.1 KB, 0 marked damaged
(STATUS is what the manifest records. To read every block and find new damage, run: kdb check)
```
| Line | Meaning |
|---|---|
| `manifest copy b` | Which of the two manifest copies is newest |
| `last shutdown` | `clean`, or `not clean` after a crash or kill (then the log was replayed) |
| `log position 30` | Number (LSN) of the last change |
| `newest change` | Time of the last change, **local time** of this computer |
| `features` | The stored-format features this database uses. An older KDB that does not know one of them refuses to open it (chapter 24). `batched_log_sync` is in every database this KDB writes: commits that come at the same moment share one write to the disk (group commit), and that needs this log format. |
| `manifest keys` | How many extra entries the manifest keeps besides the files (counters, settings). When some belong to files that are gone, the line says so; KDB removes them the next time it opens the database for writing. |
| `t/4/2026-09` | Table number 4 (tables are numbered in the order they were made), partition `2026-09` |
| `catalog` | The list of tables and columns |
| `LSN` | The file holds every change up to this log position. A checkpoint also raises it for files that had no changes, so an old table does not make KDB read old log. |
| `STATUS` | `ok`, or `DAMAGED: ...` if KDB marked the file as damaged |
`kdb info` only reads the list of files. It does **not** read the files. To find damage, use `kdb check`.
## kdb check
```powershell
kdb check market
"exit code: $LASTEXITCODE"
```
```text
checked 11 files (74.7 KB) and 1 log segments
OK: no damage found
exit code: 0
```
`kdb check` changes nothing. Run it every day (see the routine at the end). For a running server, use the console's **Check now** or `POST /api/check`.
## kdb backup
```powershell
kdb backup market backups --keep-daily 7 --keep-weekly 4
```
```text
backup created and verified: backups/20260923-121259-430
9 files, 190.0 KB, log up to 30
```
- A backup is a new folder inside `backups`. Its name is the **UTC** time: `20260923-121259-430` = 2026-09-23 12:12:59.430 UTC (17:57 Nepal time).
- It holds a copy of the data files, the needed log files, and `backup.kdb` (the list, written last). A folder without `backup.kdb` is not finished and is never used.
- After copying, KDB **reads the whole backup again**. Only then it prints "verified".
- `--keep-daily 7 --keep-weekly 4` deletes old backups: it keeps the newest backup of each of the last 7 days and each of the last 4 weeks (weeks start on Sunday, UTC). Without these options, nothing is deleted.
- `kdb backup` needs the database closed. With a running server, use `BACKUP DATABASE`, **Backup now**, or `POST /api/backup`. They work while the server keeps running.
### --hard-links
```powershell
kdb backup market backups-fast --hard-links
```
```text
backup created and verified: backups-fast/20260923-121309-580
9 files, 197.0 KB, log up to 38
```
Hard links are very fast and use no extra space. But the backup and the database then share the **same disk sectors**. If the disk goes bad, both are lost. Use hard links only as an extra "undo point" (for example before a big import), never as your only backup. The log files are always copied.
## kdb backups and verify-backup
```powershell
kdb backups backups
```
```text
BACKUP CREATED FILES SIZE LOG LSN
20260923-121259-430 2026-09-23 17:57:59 9 190.0 KB 30
1 backups
```
`CREATED` is shown in **local time**. The folder name is UTC.
```powershell
$newest = Get-ChildItem backups -Directory | Sort-Object Name | Select-Object -Last 1
kdb verify-backup "backups\$($newest.Name)"
"exit code: $LASTEXITCODE"
```
```text
OK: backup is intact
exit code: 0
```
A backup that you never tested is not a backup. Verify old backups from time to time, and do a test restore once a month (see below).
## A story: the disk is damaged
This is a real run. We made a **copy** of the database and wrote zeros into the middle of one data file, like a bad disk sector would.
> **Never do this on real data.** It is only for learning.
### Before: a backup, then more work
The backup above was made at 17:57. After it, work went on:
```sql
-- Work goes on after the backup.
INSERT INTO ochl_day VALUES ('2026-09-13', 'CITY', 505.0, 509.0, 503.1, 508.2, 91000);
UPDATE ochl_day SET volume = 41500 WHERE symbol = 'VOLT' AND time IN '2026-09-10';
```
```text
1 row(s) inserted
1 row(s) updated
```
### The damage
```powershell
# FOR LEARNING ONLY. Never do this to a real database.
# Make a copy, then write 64 zero bytes into the middle of one data file of the copy.
Copy-Item market market-test -Recurse
$file = Get-ChildItem market-test\files\t\4 -Filter *.kdb | Sort-Object Name | Select-Object -Last 1
"damaging files\t\4\$($file.Name) (table ochl_minutes)"
$fs = [System.IO.File]::OpenWrite($file.FullName)
$null = $fs.Seek(20000, [System.IO.SeekOrigin]::Begin)
$fs.Write((New-Object byte[] 64), 0, 64)
$fs.Close()
```
```text
damaging files\t\4\2026-09.v11.kdb (table ochl_minutes)
```
### Step 1: notice the problem
Queries on other tables still work. A query that needs the damaged rows refuses to answer. **It never gives wrong numbers:**
```powershell
kdb sql market-test "SELECT count(*) FROM ochl_day"
kdb sql market-test "SELECT count(*) FROM ochl_minutes"
kdb sql market-test "SELECT time, close FROM ochl_minutes WHERE symbol = 'CITY' ORDER BY time DESC LIMIT 1"
"exit code: $LASTEXITCODE"
```
```text
count(*)
--------
58
(1 row, 0.3 ms)
count(*)
--------
956
(1 row, 0.3 ms)
error: table 'ochl_minutes' partition 2026-09: market-test/files/t/4/2026-09.v11.kdb: block 0 (bytes 0-58257) failed its checksum. Run: kdb repair --backups
exit code: 2
```
- `count(*)` of `ochl_minutes` still answers, and the number is right. KDB saves the row count and the min, max and sum of each column with every file. `count`, `sum`, `min` and `max` of a whole partition come from these saved numbers, so the damaged block is not read.
- The third query needs the rows themselves. They are in the damaged block, so KDB refuses, names the file and the block, and exits with code `2`.
### Step 2: stop everything and check
Stop the server and all programs that use the database. Then:
```powershell
kdb check market-test
"exit code: $LASTEXITCODE"
```
```text
checked 12 files (78.4 KB) and 1 log segments
DAMAGED: t/4/2026-09: market-test/files/t/4/2026-09.v11.kdb: block 0 (bytes 0-58257) failed its checksum (no good previous version or backup copy)
PROBLEM: t/4/2026-09: marked DAMAGED: market-test/files/t/4/2026-09.v11.kdb: block 0 (bytes 0-58257) failed its checksum
PROBLEM: t/4/2026-09 v11 (current): 1 damaged block(s): 0
Damage found. Fix it with: kdb repair market-test --backups
exit code: 2
```
**Before a repair, copy the whole damaged folder somewhere safe** (for example `Copy-Item D:\kdb\market E:\damaged-copy -Recurse`). Then nothing you try can make things worse.
### Step 3: repair without a backup
The minute table's file has no older version (it was written only once), so the first rung of the ladder does not help:
```powershell
kdb repair market-test
"exit code: $LASTEXITCODE"
```
```text
manifest copy: b, last shutdown: clean
replayed 0 transactions after LSN 34, last LSN 34
DAMAGED: t/4/2026-09: market-test/files/t/4/2026-09.v11.kdb: block 0 (bytes 0-58257) failed its checksum (no good previous version or backup copy)
still damaged: t/4/2026-09: marked DAMAGED: market-test/files/t/4/2026-09.v11.kdb: block 0 (bytes 0-58257) failed its checksum
still damaged: t/4/2026-09 v11 (current): 1 damaged block(s): 0
Some damage could not be repaired. Restore a backup with: kdb restore
exit code: 2
```
### Step 4: repair with the backups
```powershell
kdb repair market-test --backups backups
"exit code: $LASTEXITCODE"
```
```text
manifest copy: b, last shutdown: clean
replayed 2 transactions after LSN 30, last LSN 34
REPAIRED: t/4/2026-09: market-test/files/t/4/2026-09.v11.kdb: block 0 (bytes 0-58257) failed its checksum -> restored from backup backups/20260923-121259-430 (v11) and replaying the log from LSN 30
Repaired.
exit code: 0
```
KDB took the file from the backup and then **replayed the log** from the backup's position (30) to the end. So also the changes made **after** the backup are back.
### Step 5: check again
```powershell
kdb check market-test
"exit code: $LASTEXITCODE"
kdb sql market-test "SELECT count(*) AS minute_rows FROM ochl_minutes; SELECT time, symbol, close, volume FROM ochl_day WHERE time >= '2026-09-10' ORDER BY symbol, time"
```
```text
checked 12 files (78.4 KB) and 1 log segments
OK: no damage found
exit code: 0
minute_rows
-----------
956
(1 row, 0.7 ms)
time | symbol | close | volume
-------------------------+--------+-------+-------
2026-09-10T00:00:00.000Z | CITY | 504.9 | 183040
2026-09-13T00:00:00.000Z | CITY | 508.2 | 91000
2026-09-10T00:00:00.000Z | COAST | 376.1 | 186200
2026-09-10T00:00:00.000Z | VOLT | 191.2 | 41500
(4 rows, 0.2 ms)
```
All 956 minute rows are back, and so are the two changes made after the backup (the 2026-09-13 row and the volume 41500).
Also try: the damaged file was replaced from the **previous version** when one exists. Then `kdb repair` works even without `--backups`, and the message says `using previous version v10 and replaying the log`.
## When the log is damaged
The log is only needed for changes that are **not yet saved** in the data files. `kdb sql`, `kdb shell` and a server stopped with Ctrl+C save everything when they close. So damage at the end of the log then costs nothing:
```powershell
# FOR LEARNING ONLY. Damage the end of the log in another copy.
Copy-Item market market-log -Recurse
$log = Get-ChildItem market-log\wal -Filter *.log | Sort-Object Name | Select-Object -Last 1
$fs = [System.IO.File]::OpenWrite($log.FullName)
$null = $fs.Seek(-40, [System.IO.SeekOrigin]::End)
$fs.Write([byte[]](1..32), 0, 32)
$fs.Close()
kdb check market-log
"exit code: $LASTEXITCODE"
```
```text
checked 12 files (78.4 KB) and 1 log segments
OK: no damage found
exit code: 0
```
`kdb repair` cuts the damaged end away and continues the log in a new file. Nothing is lost:
```powershell
kdb repair market-log --backups backups
"exit code: $LASTEXITCODE"
```
```text
manifest copy: a, last shutdown: clean
replayed 0 transactions after LSN 34, last LSN 34
dropped an unfinished write at the end of the log (185 bytes)
Nothing to repair.
exit code: 0
```
**After a crash** (power cut, killed server) the newest changes may be only in the log. If exactly that end is damaged, KDB cannot tell it from a write the crash cut in half, so it drops those last changes, as if the power had gone a moment earlier. After a crash, check the newest data and load it again if needed.
If log records that KDB still **needs** are damaged (not only the very end), KDB refuses to open the database and names the damaged log file. The choices, in this order:
1. `kdb repair --backups `: when a backup has those log records, KDB copies the good bytes from it.
2. `kdb restore` from the newest backup with `--logs-from `: keeps everything up to the damage.
3. `kdb repair --accept-log-loss`: opens the database and accepts that the changes in the damaged part are lost.
## A story: an UPDATE without WHERE
At about 14:05 someone runs an UPDATE and forgets the WHERE. Every close price becomes 0.
(`kdb serve --safe-updates` refuses such an UPDATE, see chapter 21. `kdb sql`, used here, does not.)
```powershell
kdb sql market "INSERT INTO ochl_day VALUES ('2026-09-14', 'CITY', 508, 512, 507, 511, 64000)"
Start-Sleep -Seconds 2
# Remember a time when the data was still good (local time of this computer).
Get-Date -Format "yyyy-MM-dd HH:mm:ss" | Set-Content good-time.txt
Start-Sleep -Seconds 2
# The mistake: an UPDATE without WHERE.
kdb sql market "UPDATE ochl_day SET close = 0"
kdb sql market "SELECT time, symbol, close FROM ochl_day WHERE time >= '2026-09-10' ORDER BY symbol, time"
```
```text
1 row(s) inserted
59 row(s) updated
time | symbol | close
-------------------------+--------+------
2026-09-10T00:00:00.000Z | CITY | 0
2026-09-13T00:00:00.000Z | CITY | 0
2026-09-14T00:00:00.000Z | CITY | 0
2026-09-10T00:00:00.000Z | COAST | 0
2026-09-10T00:00:00.000Z | VOLT | 0
(5 rows, 0.2 ms)
```
(In real life you do not have `good-time.txt`. You know roughly when the mistake happened, for example "a few minutes after 14:05". Use a time **before** the mistake.)
### Restore to the moment before the mistake
```powershell
$goodTime = Get-Content good-time.txt
$backup = Get-ChildItem backups -Directory | Sort-Object Name | Select-Object -Last 1
kdb restore "backups\$($backup.Name)" market-restored --logs-from market --until $goodTime
"exit code: $LASTEXITCODE"
kdb sql market-restored "SELECT time, symbol, close FROM ochl_day WHERE time >= '2026-09-10' ORDER BY symbol, time"
```
```text
stopped at the requested time: changes after LSN 36 are not restored
restored 9 data files from backups\20260923-121259-430 plus 1 newer log segments; the log (up to LSN 36) is replayed on first open
restored into market-restored
exit code: 0
time | symbol | close
-------------------------+--------+------
2026-09-10T00:00:00.000Z | CITY | 504.9
2026-09-13T00:00:00.000Z | CITY | 508.2
2026-09-14T00:00:00.000Z | CITY | 511
2026-09-10T00:00:00.000Z | COAST | 376.1
2026-09-10T00:00:00.000Z | VOLT | 191.2
(5 rows, 0.3 ms)
```
- The prices are back. The row for 2026-09-14, made **after** the backup but **before** the mistake, is also there.
- `--logs-from market` takes the newer log from the old database. Without it, you get the backup as it was.
- `--until "2026-09-11 14:05"` or `"2026-09-11 14:05:30"` is **local time** of this computer. Changes in that second are included.
- The target folder must be **new or empty**:
```powershell
$backup = Get-ChildItem backups -Directory | Sort-Object Name | Select-Object -Last 1
kdb restore "backups\$($backup.Name)" market-restored
"exit code: $LASTEXITCODE"
```
```text
error: Already exists: restore target is not empty: market-restored
exit code: 1
```
Then switch over:
1. Stop the server.
2. Rename the folders: `market` → `market-broken`, `market-restored` → `market`.
3. Start the server again. Keep `market-broken` until you are sure.
Check the restored database before you switch:
```powershell
kdb check market-restored
"exit code: $LASTEXITCODE"
```
```text
checked 10 files (75.9 KB) and 2 log segments
OK: no damage found
exit code: 0
```
A backup holds only the current version of each file, so a restored database has no older versions yet. They appear again as data changes.
## Daily routine for a trading database
The sample market closes at 15:00 Nepal time and trades Sunday to Thursday. Use your own market hours and days. Do this every trading day:
| When (NPT) | What | How |
|---|---|---|
| after 15:00, when the day's imports are done | Backup to **another disk** | `kdb serve --backup-schedule "daily 16:00" --backup-copy ` does it by itself; or `kdb backup` (server stopped) or `POST /api/backup` (server running). Keep 7 daily + 4 weekly. |
| right after the backup | Check the database | `kdb check` or `POST /api/check`. Exit code 2 or `"ok":false` → act now. |
| every day | Look at the log of the backup script, or for `WARNING` and `FAILED` in the server's output | chapter 7, chapter 21 |
| once a week | Copy the newest backup to a third place (another PC, NAS, cloud) | `robocopy` or your normal backup tool |
| once a month | **Test restore** into a test folder, run a few queries | `kdb restore D:\restore-test`, then `kdb sql D:\restore-test "SELECT count(*) FROM ochl_day"` |
| before a big import or a schema change | An extra backup | `kdb backup ...` (hard links are OK here) |
When a check finds damage:
1. Stop the server.
2. Copy the damaged database folder to a safe place.
3. `kdb repair --backups `.
4. `kdb check `. If it is OK, start the server again.
5. If repair fails: `kdb restore --logs-from `, check it, and switch folders.
6. Find out **why** (disk errors in Windows Event Viewer, a failing SSD). Replace bad hardware.
## Other safety facts
- **Backups while the server runs:** `POST /api/backup` and scheduled backups do not stop the server. KDB waits for an open transaction (at most a few seconds), writes a checkpoint, and copies the files while reads and writes go on.
- **Disk full:** KDB stops accepting writes when less than 64 MB is free, so a full disk never leaves something half written. A program gets HTTP 507 (`disk full`). Writes work again when there is space, without a restart.
- **Old versions:** KDB keeps the 2 previous versions of each file (for the repair ladder) and moves older ones to `_trash`. Files older than 1 hour leave the trash while the server runs. `PURGE TRASH` frees that space at once.
- **Log archive:** old log files go to `wal_archive` (up to 1 GB by default; `ALTER DATABASE ... SET WAL_ARCHIVE` or the Settings page changes it), for backups and repairs. With it `OFF`, a damaged file can only be repaired from a backup.
- **Killing the server** (closing the window, `Stop-Process`) is safe: it is like a power cut. The next start shows `last shutdown: not clean` and replays the log. But **Ctrl+C is better**: it closes the database cleanly.
## dump-file (for experts)
Shows the header and checks the blocks of one file:
```powershell
kdb dump-file market-test\files\t\4\2026-09.v11.kdb
```
For a damaged file it ends with `DAMAGED block 0` and exit code 2. Example from a damaged test file:
```text
file story/files/t/3/2026.v15.kdb
type data file
version 1
data 3.1 KB
block size 65536
LSN 34
id 3
created 2026-09-11 13:45:11
blocks 1
digest bb8fefdd
DAMAGED block 0
```
## Try it yourself
1. Make a backup of `market`, then list the backups.
2. Delete all rows of `live_data` by mistake. Restore a new database from the backup with `--logs-from` and an `--until` time from before the delete.
3. What exit code does `kdb check` give on a healthy database, and what on a damaged one?
Answers
1. ```powershell
kdb backup market backups
kdb backups backups
```
2. ```powershell
Get-Date -Format "yyyy-MM-dd HH:mm:ss" # note the time, e.g. 2026-09-11 15:40:10
kdb sql market "DELETE FROM live_data"
$b = Get-ChildItem backups -Directory | Sort-Object Name | Select-Object -Last 1
kdb restore "backups\$($b.Name)" market-undo --logs-from market --until "2026-09-11 15:40:10"
kdb sql market-undo "SELECT count(*) FROM live_data"
```
3. `0` when healthy, `2` when damage is found (`1` when it cannot run at all, for example the database is open in another program).
---
Next: [10. Moving from QuestDB](10-migrating-from-questdb.md)
---
# 10. Moving from QuestDB
- Category: Tutorial
## What you will learn
- The tables of the 006 app (NChartPro) as KDB tables.
- What is different between QuestDB SQL and KDB SQL.
- How to move the data (CSV export, then `/imp` or INSERT batches).
The examples start with an **empty** database `market`. The files are in `examples/10-migrating-from-questdb/`.
## First: what works today
| 006 uses | QuestDB | KDB |
|---|---|---|
| Reads with Go `pgx` (Postgres protocol) | port 8812 | **yes**, port 8832 (chapter 14). Change the port in the connection string. |
| Bulk writes with HTTP `/imp` (CSV upload) | port 9000 | **yes**, the same parameters (chapter 8) |
| SAT3 writes with ILP (InfluxDB line protocol) | port 9009 (TCP) or HTTP `/write` | **yes, over HTTP**: `POST /write` (also `/api/v2/write`) on port 9900. There is no TCP port for ILP. The tables must exist first. |
| `/exec` JSON (`columns`, `dataset`, `count`) | yes | **yes**, same top-level fields (plus `results`) |
| `/exp` CSV export | yes | **yes**, for reading statements |
| Web console | yes | yes, with charts, dashboards and health (chapters 6 and 20) |
So the 006 Go code can keep `pgx`: point it at port 8832. The SQL itself mostly stays the same. `ALTER DATABASE name SET dialect = 'questdb'` makes KDB read a few things the QuestDB way (chapter 23).
## The 006 tables in KDB
The KDB tables have the **same names and columns** as in QuestDB. Names do not care about case, with or without double quotes.
```sql
-- The tables of the 006 app, in KDB. Names and columns are the same as in QuestDB.
CREATE TABLE IF NOT EXISTS "Ochl_Day" (
time TIMESTAMP,
symbol SYMBOL,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume DOUBLE
) TIMESERIES(time) PARTITION BY YEAR DEDUP KEY (time, symbol);
CREATE TABLE IF NOT EXISTS "Ochl_Day_Unadj" (
time TIMESTAMP, symbol SYMBOL, open DOUBLE, high DOUBLE, low DOUBLE, close DOUBLE, volume DOUBLE
) TIMESERIES(time) PARTITION BY YEAR DEDUP KEY (time, symbol);
CREATE TABLE IF NOT EXISTS "Ochl_Minutes" (
time TIMESTAMP, symbol SYMBOL, open DOUBLE, high DOUBLE, low DOUBLE, close DOUBLE, volume DOUBLE
) TIMESERIES(time) PARTITION BY MONTH DEDUP KEY (time, symbol);
CREATE TABLE IF NOT EXISTS "Ochl_Minutes_Adj" (
time TIMESTAMP, symbol SYMBOL, open DOUBLE, high DOUBLE, low DOUBLE, close DOUBLE, volume DOUBLE
) TIMESERIES(time) PARTITION BY MONTH DEDUP KEY (time, symbol);
CREATE TABLE IF NOT EXISTS "TradeLog" (
date TIMESTAMP,
transaction_id LONG,
symbol SYMBOL,
buyer INT,
seller INT,
quantity LONG,
rate DOUBLE,
amount DOUBLE
) TIMESERIES(date) PARTITION BY DAY DEDUP KEY (date, transaction_id);
CREATE TABLE IF NOT EXISTS "LiveData" (
symbol SYMBOL,
ltp DOUBLE,
ltv LONG,
point_change DOUBLE,
pct_change DOUBLE,
open DOUBLE,
high DOUBLE,
low DOUBLE,
avg_traded_price DOUBLE,
prev_close DOUBLE,
volume LONG,
timestamp TIMESTAMP
) TIMESERIES(timestamp) PARTITION BY DAY DEDUP KEY (timestamp, symbol);
SHOW TABLES;
```
```text
table 'Ochl_Day' created
table 'Ochl_Day_Unadj' created
table 'Ochl_Minutes' created
table 'Ochl_Minutes_Adj' created
table 'TradeLog' created
table 'LiveData' created
name | kind | partition | columns | rows
-----------------+------------+-----------+---------+-----
TradeLog | timeseries | DAY | 8 | 0
LiveData | timeseries | DAY | 12 | 0
Ochl_Day | timeseries | YEAR | 7 | 0
Ochl_Day_Unadj | timeseries | YEAR | 7 | 0
Ochl_Minutes | timeseries | MONTH | 7 | 0
Ochl_Minutes_Adj | timeseries | MONTH | 7 | 0
(6 rows, 0.0 ms)
```
How the QuestDB DDL maps to KDB:
| QuestDB | KDB |
|---|---|
| `TIMESTAMP(time)` after the column list | `TIMESERIES(time)` |
| `PARTITION BY DAY/MONTH/YEAR` | same |
| `WAL` | remove it (KDB always has a log) |
| `symbol SYMBOL INDEX` | `symbol SYMBOL` is enough: rows are sorted by symbol inside each partition. `SYMBOL INDEX` is accepted too and makes an index (`q_s_idx`). |
| `DEDUP UPSERT KEYS(time, symbol)` | `DEDUP KEY (time, symbol)`, and write with `INSERT OR REPLACE` (or run `ALTER DATABASE name SET dialect = 'questdb'` once: then a plain `INSERT` replaces too, and `CAST(time AS LONG)` gives microseconds; chapter 23). The QuestDB words are accepted as well. |
| `ALTER TABLE ... DEDUP ENABLE UPSERT KEYS(...)` / `DEDUP DISABLE` | same. `DEDUP ENABLE` on a table with rows folds rows with the same key into one (the newest is kept). |
| `CREATE TABLE ... AS (SELECT ...)` | same: `table 'x' created with 57 row(s)` |
The QuestDB `CREATE TABLE` text itself (`TIMESTAMP(time) PARTITION BY DAY WAL DEDUP UPSERT KEYS(...)`) is accepted as it is. The KDB form above is easier to read.
Design notes for these tables:
- **DEDUP stays on.** In 006, dedup on `Ochl_*` was switched off during big backfills because QuestDB's merge was slow. In KDB, the DEDUP KEY check is part of every insert and is fast, so you can leave it on. Test the speed of your backfill with a real file first. (`POST /write` and `POST /imp` also take `dedup=skip` for rows you know are new.)
- **Minute symbols** in `Ochl_Minutes` use the `-1` suffix (`CITY-1`) in 006. KDB does not care; keep it the same so the Go code does not change. `replace(symbol, '-1', '')` removes it in a query.
- **ROUND is optional.** `time TIMESTAMP ROUND 1d` on `Ochl_Day` and `date TIMESTAMP ROUND 1d` on `TradeLog` would force times to 00:00 UTC. That would make a trade with a wrong sub-day time a duplicate of the right row, instead of a second row. But it would also merge an intraday junk row into the real day row (`INSERT OR REPLACE` keeps the last one). Decide after you clean the data.
- **amount** in `TradeLog` is a stored column here, because the imports send it. It could be a computed column `amount DOUBLE AS (quantity * rate)` (chapter 3), if the source value is always `quantity * rate`.
- **REFERENCES** to a brokers table would check every trade, but it slows down big imports. Leave it out for the migration.
- **Timestamps:** QuestDB stores microseconds; KDB stores milliseconds. Daily market data like this has no sub-millisecond times, so nothing is lost. Digits after the milliseconds are dropped when you insert.
## Query differences
Most 006 queries run unchanged. These are the differences:
| Task | QuestDB | KDB |
|---|---|---|
| 1-hour candles on the Nepal clock | `SAMPLE BY 1h ALIGN TO CALENDAR WITH OFFSET '00:15'` | same, or `SAMPLE BY 1h TIME ZONE 'Asia/Kathmandu'` |
| Weekly candles starting Sunday | not possible in SQL (006 folds days in Go: `rollupWeeklySunday`) | `SAMPLE BY 1w WEEK START SUNDAY` |
| Monthly candles | `SAMPLE BY 1M` | same |
| Newest row per symbol | `LATEST ON timestamp PARTITION BY symbol` | same |
| Delete some rows | not possible (006 rebuilds tables with `CREATE TABLE AS`) | `DELETE FROM t WHERE ...` |
| Change some rows | `UPDATE` (slow on WAL tables) | `UPDATE t SET ... WHERE ...` |
| Drop a partition | `ALTER TABLE t DROP PARTITION LIST '2026-09'` | `ALTER TABLE t DROP PARTITION '2026-09'` (no `LIST`) |
| Count rows | `count()` or `count(*)` | same |
| Time interval | `time IN '2026-09-10'` | same. The QuestDB form with `;` (`'2026-09-10;1d'`) is not available. |
| Row range | `LIMIT 10,20` | same (rows 11 to 20), or `LIMIT 10 OFFSET 10` |
| Bucket a time | `timestamp_floor('1h', time)` | same, or `date_trunc('hour', time)` |
| Nepal clock | `to_timezone(time, 'Asia/Kathmandu')` | same |
| Implicit GROUP BY | yes | yes, same rule |
| Casts | `x::DOUBLE` | same |
| Remove all rows | `TRUNCATE TABLE t` | same |
| Joins | `JOIN`, `LEFT JOIN`, `ASOF JOIN`, `LT JOIN`, `SPLICE JOIN` | the same, and `RIGHT JOIN`, `FULL JOIN`, `CROSS JOIN`, `JOIN ... USING`. `SPLICE JOIN ... ON` with several keys keeps every row of both tables (QuestDB 9.4.3 drops some); no `TOLERANCE` yet |
| Indicators | few built in | `sma`, `ema`, `rsi`, `atr`, `lag`, `pct_change`, ... (chapter 5) |
### Hourly candles (candles_get.go)
```sql
INSERT INTO "Ochl_Minutes" VALUES
('2026-09-10T11:00:00+05:45', 'CITY-1', 504.6, 504.9, 504.3, 504.4, 280),
('2026-09-10T11:59:00+05:45', 'CITY-1', 505.1, 505.4, 505.0, 505.3, 900),
('2026-09-10T12:00:00+05:45', 'CITY-1', 505.3, 505.5, 505.2, 505.2, 400);
-- QuestDB (006): SAMPLE BY 1h ALIGN TO CALENDAR WITH OFFSET '00:15'
-- KDB:
SELECT time, first(open) open, max(high) high, min(low) low, last(close) close, sum(volume) volume
FROM "Ochl_Minutes"
WHERE symbol = 'CITY-1' AND time IN '2026-09-10'
SAMPLE BY 1h TIME ZONE 'Asia/Kathmandu'
ORDER BY time DESC
LIMIT 500;
```
```text
3 row(s) inserted
time | open | high | low | close | volume
-------------------------+-------+-------+-------+-------+-------
2026-09-10T06:15:00.000Z | 505.3 | 505.5 | 505.2 | 505.2 | 400
2026-09-10T05:15:00.000Z | 504.6 | 505.4 | 504.3 | 505.3 | 1180
(2 rows, 0.2 ms)
```
The bucket times are the same as QuestDB's with the `00:15` offset (`05:15Z` = 11:00 NPT).
`WITH OFFSET` gives the same buckets:
```sql
-- The same with WITH OFFSET, written with a sign.
SELECT time, first(open) open, last(close) close
FROM "Ochl_Minutes"
WHERE symbol = 'CITY-1'
SAMPLE BY 1h ALIGN TO CALENDAR WITH OFFSET '+00:15';
```
```text
time | open | close
-------------------------+-------+------
2026-09-10T05:15:00.000Z | 504.6 | 505.3
2026-09-10T06:15:00.000Z | 505.3 | 505.2
(2 rows, 0.3 ms)
```
```sql
-- The exact QuestDB text, without the sign, also works ('00:15' means '+00:15'):
SELECT time, first(open) open, last(close) close
FROM "Ochl_Minutes"
WHERE symbol = 'CITY-1'
SAMPLE BY 1h ALIGN TO CALENDAR WITH OFFSET '00:15';
```
```text
time | open | close
-------------------------+-------+------
2026-09-10T05:15:00.000Z | 504.6 | 505.3
2026-09-10T06:15:00.000Z | 505.3 | 505.2
(2 rows, 0.2 ms)
```
### Weekly candles (candles_weekly.go)
```sql
-- 006 folds day rows into Sunday weeks in Go (rollupWeeklySunday). KDB does it in SQL.
-- Day rows are at 00:00 UTC, so no TIME ZONE is needed here.
SELECT time, first(open) open, max(high) high, min(low) low, last(close) close, sum(volume) volume
FROM "Ochl_Day"
WHERE symbol = 'CITY'
SAMPLE BY 1w WEEK START SUNDAY
ORDER BY time DESC
LIMIT 500;
```
```text
time | open | high | low | close | volume
-------------------------+------+-------+-------+-------+-------
2026-09-06T00:00:00.000Z | 512 | 512.4 | 500.7 | 504.9 | 365430
(1 row, 0.3 ms)
```
The bucket time is Sunday 00:00 UTC, the same value `weekBucketStartSunday` makes in Go. (Do **not** add `TIME ZONE 'Asia/Kathmandu'` here: the bucket would then be stamped Saturday 18:15 UTC.)
### Deleting rows
```sql
-- QuestDB cannot delete rows (006 rebuilds tables with CREATE TABLE AS). KDB can:
DELETE FROM "Ochl_Minutes" WHERE symbol = 'CITY-1' AND time < '1990-01-01';
DELETE FROM "TradeLog" WHERE date IN '2026-09-10' AND transaction_id = 100003;
SELECT count(*) AS trade_log_rows FROM "TradeLog";
```
```text
0 row(s) deleted
1 row(s) deleted
trade_log_rows
---------------
2
(1 row, 0.1 ms)
```
Code like `holiday_sweep.go`, `minute_day_replace.go` and `trade_log_purge.go` (copy table, filter, swap) can become one `DELETE` plus `INSERT OR REPLACE`.
### LATEST ON (live_movers.go)
```sql
INSERT INTO "LiveData" (symbol, ltp, pct_change, volume, timestamp) VALUES
('CITY', 513.0, 0.4, 12000, '2026-09-10T11:05:00+05:45'),
('CITY', 527.1, 2.71, 90500, '2026-09-10T14:59:00+05:45'),
('COAST', 401.9, 0.7, 40100, '2026-09-10T14:59:00+05:45');
-- Same text as live_movers.go:
SELECT symbol, ltp, point_change, pct_change, volume FROM "LiveData"
WHERE symbol IN ('CITY', 'COAST') AND timestamp >= '2026-09-10T00:00:00.000000Z'
LATEST ON timestamp PARTITION BY symbol;
```
```text
3 row(s) inserted
symbol | ltp | point_change | pct_change | volume
-------+-------+--------------+------------+-------
CITY | 527.1 | NULL | 2.71 | 90500
COAST | 401.9 | NULL | 0.7 | 40100
(2 rows, 0.2 ms)
```
### Names
```sql
-- Table and column names do not care about upper or lower case, with or without quotes.
SELECT count(*) AS a FROM "Ochl_Day";
SELECT count(*) AS b FROM ochl_day;
SELECT count(*) AS c FROM OCHL_DAY;
```
All three return the same count (5 after the import below).
## Moving the data
The plan:
1. **Export** each table (or each year or month) from QuestDB as CSV with `/exp`.
2. **Convert** the CSV into `INSERT OR REPLACE` statements with `csv-to-sql.ps1`.
3. **Load** the SQL into KDB with `kdb sql` (KDB server stopped) or `POST /exec` (server running).
4. **Compare** row counts and a few sums between QuestDB and KDB.
### 1. Export from QuestDB
Run this against your QuestDB (port 9000). **Not run while writing this tutorial** (there was no QuestDB on the test PC):
```powershell
$qdb = "http://127.0.0.1:9000"
curl.exe -s -G "$qdb/exp" --data-urlencode "query=SELECT * FROM 'Ochl_Day' WHERE time IN '2026'" -o Ochl_Day_2026.csv
curl.exe -s -G "$qdb/exp" --data-urlencode "query=SELECT * FROM 'Ochl_Minutes' WHERE time IN '2026-09'" -o Ochl_Minutes_2026-09.csv
curl.exe -s -G "$qdb/exp" --data-urlencode "query=SELECT * FROM 'TradeLog' WHERE date IN '2026-09-10'" -o TradeLog_2026-09-10.csv
```
- Export big tables **one partition at a time** (a year, a month or a day). Files stay small, and a failed step is easy to repeat.
- Use `SELECT *` so the columns come in table order. Or list the columns: the script uses the header line as the column list.
A QuestDB CSV looks like this. The sample file `Ochl_Day.csv` in the examples folder was **written by hand** in this style (times with 6 digits, quoted header and text). The script accepts the values with or without quotes.
```text
"time","symbol","open","high","low","close","volume"
2026-09-09T00:00:00.000000Z,"CITY",512.0,512.4,504.0,504.6,182390.0
2026-09-09T00:00:00.000000Z,"COAST",398.0,398.8,379.2,380.1,186330.0
2026-09-10T00:00:00.000000Z,"CITY",504.6,507.0,500.7,504.9,183040.0
2026-09-10T00:00:00.000000Z,"COAST",380.1,382.0,373.2,376.1,186200.0
2026-09-10T00:00:00.000000Z,"VOLT",187.3,192.1,184.2,191.2,41470.0
```
The simplest way to load such a file into a running KDB server is `POST /imp`, the same call QuestDB takes (chapter 8):
```powershell
curl.exe -s -F "data=@Ochl_Day_2026.csv" "http://127.0.0.1:9900/imp?name=Ochl_Day"
```
The steps below do the same without a server: they turn the CSV into SQL and load it with `kdb sql`.
### 2. Convert: csv-to-sql.ps1
The script examples/10-migrating-from-questdb/csv-to-sql.ps1 reads the CSV line by line and writes INSERT statements:
- Values that look like numbers stay numbers. Everything else becomes `'text'` (times, symbols). Empty values become NULL.
- One `INSERT OR REPLACE` per `-BatchSize` rows (default 5000). `-OnDuplicate IGNORE` or `NONE` change the verb.
- It works in Windows PowerShell 5.1 and PowerShell 7.
Here with a batch size of 3, so you can see two statements:
```powershell
.\csv-to-sql.ps1 -Csv Ochl_Day.csv -Table Ochl_Day -Out Ochl_Day.sql -BatchSize 3
Get-Content Ochl_Day.sql
```
```text
5 rows from Ochl_Day.csv written to Ochl_Day.sql
INSERT OR REPLACE INTO "Ochl_Day" ("time", "symbol", "open", "high", "low", "close", "volume") VALUES
('2026-09-09T00:00:00.000000Z', 'CITY', 512.0, 512.4, 504.0, 504.6, 182390.0),
('2026-09-09T00:00:00.000000Z', 'COAST', 398.0, 398.8, 379.2, 380.1, 186330.0),
('2026-09-10T00:00:00.000000Z', 'CITY', 504.6, 507.0, 500.7, 504.9, 183040.0);
INSERT OR REPLACE INTO "Ochl_Day" ("time", "symbol", "open", "high", "low", "close", "volume") VALUES
('2026-09-10T00:00:00.000000Z', 'COAST', 380.1, 382.0, 373.2, 376.1, 186200.0),
('2026-09-10T00:00:00.000000Z', 'VOLT', 187.3, 192.1, 184.2, 191.2, 41470.0);
```
### 3. Load
```powershell
$OutputEncoding = New-Object System.Text.UTF8Encoding $false
Get-Content Ochl_Day.sql -Raw -Encoding UTF8 | kdb sql market -
"exit code: $LASTEXITCODE"
kdb sql market "SELECT * FROM Ochl_Day"
```
```text
3 row(s) inserted
2 row(s) inserted
exit code: 0
time | symbol | open | high | low | close | volume
-------------------------+--------+-------+-------+-------+-------+-------
2026-09-09T00:00:00.000Z | CITY | 512 | 512.4 | 504 | 504.6 | 182390
2026-09-10T00:00:00.000Z | CITY | 504.6 | 507 | 500.7 | 504.9 | 183040
2026-09-09T00:00:00.000Z | COAST | 398 | 398.8 | 379.2 | 380.1 | 186330
2026-09-10T00:00:00.000Z | COAST | 380.1 | 382 | 373.2 | 376.1 | 186200
2026-09-10T00:00:00.000Z | VOLT | 187.3 | 192.1 | 184.2 | 191.2 | 41470
(5 rows, 0.3 ms)
```
Loading the same file again is safe:
```powershell
# Running the same import again is safe: INSERT OR REPLACE finds the rows by (time, symbol).
Get-Content Ochl_Day.sql -Raw -Encoding UTF8 | kdb sql market -
kdb sql market "SELECT count(*) AS rows FROM Ochl_Day"
```
```text
0 row(s) inserted, 3 replaced
0 row(s) inserted, 2 replaced
rows
----
5
(1 row, 0.2 ms)
```
Duplicates inside the file are handled too. `TradeLog.csv` has transaction 100002 twice:
```powershell
# TradeLog.csv has transaction 100002 twice. DEDUP KEY (date, transaction_id) keeps one row.
.\csv-to-sql.ps1 -Csv TradeLog.csv -Table TradeLog -Out TradeLog.sql
Get-Content TradeLog.sql -Raw -Encoding UTF8 | kdb sql market -
kdb sql market "SELECT date, transaction_id, symbol, buyer, seller, quantity, rate, amount FROM TradeLog"
```
```text
4 rows from TradeLog.csv written to TradeLog.sql
3 row(s) inserted, 1 replaced
date | transaction_id | symbol | buyer | seller | quantity | rate | amount
-------------------------+----------------+--------+-------+--------+----------+-------+-------
2026-09-10T00:00:00.000Z | 100001 | CITY | 58 | 45 | 100 | 515.5 | 51550
2026-09-10T00:00:00.000Z | 100002 | COAST | 45 | 1 | 1000 | 398 | 398000
2026-09-10T00:00:00.000Z | 100003 | VOLT | 34 | 58 | 200 | 184 | 36800
(3 rows, 0.2 ms)
```
**While the KDB server runs**, send the file with POST (chapter 8). The body may be up to 64 MB, so split very big SQL files (for example one file per month):
```powershell
$body = [System.IO.File]::ReadAllBytes((Resolve-Path Ochl_Day.sql))
$r = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/exec?compat=0" -Body $body -ContentType "text/plain; charset=utf-8"
$r.results | ForEach-Object { $_.message }
```
Speed: on the test PC, loading 24,000 minute rows in 5 statements of 5000 rows took about 0.6 seconds with `kdb sql`. The PowerShell conversion script is slower than the load; for many millions of rows you may want to convert in Go or Python (same rules: numbers stay numbers, other values in single quotes with `'` doubled).
### 4. Compare
Run the same checks on both databases:
```sql
SELECT count(*) AS rows, min(time) AS first, max(time) AS last, sum(volume) AS volume
FROM Ochl_Day WHERE time IN '2026';
SELECT date, count(*) AS trades, sum(quantity) AS shares, sum(amount) AS amount
FROM TradeLog WHERE date IN '2026-09' SAMPLE BY 1d;
```
The numbers must be the same. Also open a few charts in both consoles.
## A migration checklist
1. Build KDB, run the tests (chapter 1).
2. Create the tables (above). Keep the SQL in a file in the 006 repo.
3. Export and load one small table (`LiveData` of one day). Compare.
4. Load `Ochl_Day`, one year at a time. Compare counts per year.
5. Load `Ochl_Minutes` and `Ochl_Minutes_Adj`, one month at a time.
6. Load `TradeLog`, one day or month at a time.
7. Change the queries that use the differences above (`DROP PARTITION LIST`, `'2026-09-10;1d'`).
8. Point the Go `pgx` connection at port 8832 (chapter 14), and the ILP writers at `POST /write` on port 9900.
9. Run 006 against KDB on a test PC for some days, next to QuestDB.
10. Set up the daily backup and check (chapters 7 and 9), and test a restore.
## Try it yourself
1. Write the KDB `CREATE TABLE` for a QuestDB table `Index_Minutes (time TIMESTAMP, index_name SYMBOL INDEX, value DOUBLE) TIMESTAMP(time) PARTITION BY MONTH WAL DEDUP UPSERT KEYS(time, index_name)`.
2. Rewrite for KDB: `SELECT * FROM Ochl_Day WHERE symbol = 'CITY' ORDER BY time DESC LIMIT 20,40`.
3. Rewrite for KDB: `SELECT timestamp_floor('1d', time) AS day, count() FROM LiveData GROUP BY day`.
Answers
1. ```sql
CREATE TABLE IF NOT EXISTS "Index_Minutes" (
time TIMESTAMP, index_name SYMBOL, value DOUBLE
) TIMESERIES(time) PARTITION BY MONTH DEDUP KEY (time, index_name);
```
2. ```sql
SELECT * FROM Ochl_Day WHERE symbol = 'CITY' ORDER BY time DESC LIMIT 20 OFFSET 20;
```
(QuestDB `LIMIT 20,40` means rows 21 to 40. KDB also takes `LIMIT 20,40` as it is.)
3. ```sql
SELECT timestamp, count(*) AS n FROM LiveData SAMPLE BY 1d;
```
or `SELECT date_trunc('day', timestamp) AS day, count(*) AS n FROM LiveData GROUP BY date_trunc('day', timestamp);`. The QuestDB text with `timestamp_floor` and `count()` also runs as it is.
---
Next: [11. SQL reference](11-sql-reference.md)
---
# 11. SQL reference
- Category: Tutorial
## What you will learn
- The exact form of every KDB statement.
- Every type, function and operator, with a short example.
- The time rules, reserved words and limits.
This is a short reference. Chapters 3 to 5 explain everything with longer examples. In the syntax, `[x]` means "x is optional", `a | b` means "a or b", and `...` means "more of the same".
The example results in this chapter are real. They come from `examples/11-sql-reference/`.
## General rules
| Rule | Detail |
|---|---|
| Statements | End with `;`. Many statements can be sent at once. Each one is saved on its own (statements between `BEGIN` and `COMMIT`, and a whole `CALL`, are saved together); KDB stops at the first error. |
| Case | Keywords, table names, column names and function names do not care about case. Text values do (`'CITY'` is not `'city'`). |
| Names | Letters, digits, `_`, and non-English letters. A reserved word or a special name must be in double quotes: `"order"`. |
| Text | In single quotes: `'CITY'`. A quote inside is doubled: `'City''s'`. |
| Numbers | `42`, `-7`, `512.45`, `1.5e3`. Integers from -9223372036854775808 to 9223372036854775807. |
| Other constants | `TRUE`, `FALSE`, `NULL` |
| Comments | `-- to the end of the line` and `/* ... */` |
| Errors | Say where: `line 1, column 22: ...` |
## Statements
### Tables
```sql
CREATE TABLE [IF NOT EXISTS] name (
column TYPE [PRIMARY KEY [AUTO]] [NOT NULL] [[CONSTRAINT name] UNIQUE] [DEFAULT expr]
[[CONSTRAINT name] REFERENCES table[(column)] [ON DELETE action] [ON UPDATE action]]
[ROUND interval] [AS (expr)] [[CONSTRAINT name] CHECK (condition)],
...
[, [CONSTRAINT name] CHECK (condition) ...]
[, [CONSTRAINT name] PRIMARY KEY (column, ...)]
[, [CONSTRAINT name] UNIQUE (column, ...)]
[, [CONSTRAINT name] FOREIGN KEY (column, ...) REFERENCES table (column, ...) [ON DELETE action] [ON UPDATE action]]
)
[TIMESERIES(time_column)]
[PARTITION BY DAY | MONTH | YEAR | NONE]
[SORT BY (column, ...)]
[DEDUP KEY (column, ...)]
[TTL n HOURS | DAYS | WEEKS | MONTHS | YEARS]
[RECORDS];
DROP TABLE [IF EXISTS] name;
TRUNCATE [TABLE] name [, name ...]; -- removes every row at once; not inside BEGIN ... COMMIT
RENAME TABLE old TO new;
ALTER TABLE name ADD [COLUMN] column TYPE [rules];
ALTER TABLE name DROP [COLUMN] column;
ALTER TABLE name RENAME [COLUMN] old TO new;
ALTER TABLE name DROP PARTITION 'key' [, 'key' ...];
ALTER TABLE name DROP PARTITION OLDER THAN n DAYS | MONTHS | YEARS | ...;
ALTER TABLE name SET TTL n DAYS | ... | NONE;
ALTER TABLE name DEDUP ENABLE UPSERT KEYS (column, ...); -- or DEDUP DISABLE; rewrites the rows
ALTER TABLE name ADD [CONSTRAINT cname] CHECK (condition);
ALTER TABLE name DROP CONSTRAINT cname;
-- PostgreSQL forms (chapter 22):
CREATE TABLE name AS SELECT ...;
ALTER TABLE [IF EXISTS] name action [, action ...];
-- ADD [COLUMN] [IF NOT EXISTS] ..., DROP [COLUMN] [IF EXISTS] c [CASCADE],
-- ALTER [COLUMN] c {SET DEFAULT expr | DROP DEFAULT | SET NOT NULL | DROP NOT NULL | TYPE type [USING expr]},
-- ADD [CONSTRAINT n] {UNIQUE (...) | PRIMARY KEY (...) | FOREIGN KEY (...) REFERENCES ... | CHECK (...)} [NOT VALID],
-- DROP CONSTRAINT [IF EXISTS] n, RENAME [COLUMN] a TO b, RENAME TO new
SHOW TABLES;
SHOW COLUMNS FROM name;
SHOW CREATE TABLE name;
SHOW VIEWS;
```
| Part | Rules |
|---|---|
| `TIMESERIES(col)` | Makes a time-series table. `col` is TIMESTAMP or DATE. |
| `PARTITION BY` | Time-series only. Default `DAY`. Partition keys: `'2026'`, `'2026-09'`, `'2026-09-10'` (UTC). |
| `SORT BY` | Time-series only. Default: first SYMBOL column, then the time column. |
| `DEDUP KEY` | Time-series only. Must include the time column. |
| `TTL n DAYS` | Time-series only. Partitions whose last moment is older than that are dropped by themselves. `SET TTL NONE` stops it. `DROP PARTITION OLDER THAN` does the same once, now. |
| `TRUNCATE` | Removes every row, and keeps the table, its columns and indexes. Much faster than `DELETE FROM t`. Returns `1 table(s) truncated (57 row(s) removed)`. `ROLLBACK` cannot undo it, so it is refused inside `BEGIN` ... `COMMIT`. Needs the `write` role (`TRUNCATE ALL TABLES`: `admin`). |
| `RECORDS` | Says "relational table" explicitly. Cannot be used with TIMESERIES. |
| `PRIMARY KEY` | At most one per table. Implies NOT NULL. |
| `AUTO` | Integer PRIMARY KEY only. Next value from the table's counter, which only goes up (never reuses ids of deleted or rolled-back rows). |
| `DEFAULT` | A constant or `now()`. No column names. |
| `AS (expr)` | Computed column, not stored. Uses stored columns of the same row only. No DEFAULT, keys or REFERENCES. Cannot be set by INSERT/UPDATE. |
| `REFERENCES t(c)` | `t.c` must be PRIMARY KEY or UNIQUE and of the same kind of type. Checked on INSERT and UPDATE. Without `ON DELETE`, a referenced row cannot be deleted; a referenced table cannot be dropped. `REFERENCES t` means t's PRIMARY KEY. |
| `ON DELETE` / `ON UPDATE` | `NO ACTION` (default), `RESTRICT`, `CASCADE`, `SET NULL`, `SET DEFAULT`. All changes of a statement are one transaction. Chapter 22. |
| `PRIMARY KEY (a, b)` | Rows are stored by the key. |
| PostgreSQL type names | `SERIAL`, `VARCHAR(n)`, `NUMERIC(p,s)`, `UUID`, `TEXT[]`, ... See [Types](#types) and chapter 22. |
| `ROUND interval` | TIMESTAMP only. Cuts values down: `ROUND 1m`, `ROUND 1d`. |
| `CHECK (condition)` | Checked for every written row (INSERT, UPDATE, upserts, `InsertRows`, line protocol). A row where it is false is refused; NULL passes. Only stored columns of the same row and built-in functions. Name when not given: `table_column_check` / `table_check`. `ADD CHECK` refuses when a row already breaks it. Chapter 15. |
| `ADD COLUMN ... NOT NULL` | Needs a DEFAULT. |
| `DROP COLUMN` | Not for: the TIMESERIES column, SORT BY / DEDUP KEY columns, the PRIMARY KEY, referenced columns, columns used by computed columns or indexes, the only column. |
### Writing
```sql
[WITH name AS (query), ...]
INSERT [OR REPLACE | OR IGNORE] INTO table [AS alias] [(column, ...)]
VALUES (value, ...) [, (value, ...) ...] | SELECT ... | DEFAULT VALUES
[ON CONFLICT [(column, ...) [WHERE ...] | ON CONSTRAINT name]
DO NOTHING | DO UPDATE SET column = expr, ... [WHERE condition]] -- chapter 23
[RETURNING expr [[AS] alias], ... | *];
UPDATE table [[AS] alias] SET column = expr [, column = expr ...] [FROM source, ...] [WHERE condition] [RETURNING ...];
DELETE FROM table [[AS] alias] [USING source, ...] [WHERE condition] [RETURNING ...];
```
- `RETURNING` gives rows like a SELECT: the inserted rows (with AUTO ids and defaults), the rows after an UPDATE, the deleted rows.
- Values are constants or expressions without columns (`now()`, `1 + 1`, `CAST(...)`), and subqueries.
- `ON CONFLICT`, `UPDATE ... FROM`, `DELETE ... USING`, `WITH x AS (UPDATE ... RETURNING ...)` and subqueries (also correlated ones) in `SET` and `WHERE`: chapter 23.
- There is no `MERGE`. To empty a table, use `TRUNCATE` (see [Tables](#tables)).
### Queries
```sql
[WITH [RECURSIVE] name [(column, ...)] AS (query), ...]
SELECT [DISTINCT | DISTINCT ON (expr, ...)] expr [[AS] alias], ... | * | table.*
[FROM table | view | (SELECT ...) [[AS] alias [(column, ...)]] | (VALUES (...), ...) AS alias (column, ...)
| unnest(array) [AS alias (column)] | [LEFT JOIN] LATERAL (SELECT ...) alias
[[INNER] JOIN | LEFT [OUTER] JOIN source ON condition]
[ASOF JOIN source [ON condition]]
[CROSS JOIN source | , source] ...]
[WHERE condition]
[LATEST ON time_column PARTITION BY column, ...]
[SAMPLE BY interval [FILL(NONE | NULL | PREV | LINEAR | constant)]
[ALIGN TO CALENDAR | ALIGN TO FIRST OBSERVATION]
[TIME ZONE 'zone'] [WITH OFFSET '+HH:MM']
[WEEK START SUNDAY | MONDAY]]
[GROUP BY expr, ...]
[HAVING condition]
[UNION [ALL] | INTERSECT [ALL] | EXCEPT [ALL] SELECT ... ...]
[ORDER BY expr [ASC | DESC] [NULLS FIRST | NULLS LAST], ...]
[LIMIT n | ALL] [OFFSET m [ROWS]] [FETCH FIRST n ROWS ONLY]
[FOR UPDATE]; -- locks the tables it reads until the transaction ends (chapter 15)
EXPLAIN SELECT ...;
```
| Rule | Detail |
|---|---|
| Implicit GROUP BY | With aggregates or SAMPLE BY and no GROUP BY, every select item without an aggregate is a group key. |
| SAMPLE BY | Time-series tables (or subqueries with a TIMESTAMP column). The output time is the bucket start. |
| SAMPLE BY intervals | `ms`, `s`, `m`, `h`, `d`, `w`, `M` (month), `y` (year). `m` = minute, `M` = month. |
| WEEK START | Only with a week interval. Default Monday. |
| ALIGN TO FIRST OBSERVATION | Not with TIME ZONE, WITH OFFSET, months or years. |
| FILL | Applies to all aggregate columns. Fills only between the first and last bucket with data. At most 10 million rows. |
| LATEST ON | One table, no joins. Runs after WHERE. |
| ASOF JOIN | Both sides need a time column. Takes the last right row at or before the left row's time. |
| ORDER BY | Output column name, alias, position (`ORDER BY 2`), or an expression. Default NULL position: NULLS LAST for ASC, NULLS FIRST for DESC. |
| UNION, INTERSECT, EXCEPT | Same number of columns. Without `ALL` the rows are distinct. `INTERSECT` goes before `UNION` and `EXCEPT`. ORDER BY / LIMIT at the end apply to all rows and must use output names or positions. |
| Subqueries | `(SELECT ...)` as a value (must return 0 or 1 row), `IN (SELECT ...)`, `EXISTS (SELECT ...)`, `FROM (SELECT ...)`, `LATERAL`. They may use outer columns (correlated); `inner.col = outer.col` becomes a hash lookup (chapter 23). |
| Aggregates | `agg(x) FILTER (WHERE cond)`, `array_agg(x ORDER BY y)`, `GROUP BY 1` (a select column), aggregates in ORDER BY and HAVING. |
| Window functions | With GROUP BY or SAMPLE BY they run over the groups, one row per group. Their arguments must be aggregates or group keys: `sma(last(close), 2)`, `rank() OVER (ORDER BY sum(volume) DESC)`. `sma(close, 2)` there is an error (`column 'close' is used by a window function in a query with aggregates, so it must be a key of the groups`). Not inside each other (`lag(sma(close, 3))`): use a subquery. |
| Big GROUP BY | When the groups need more than half of `--max-result-memory`, KDB writes them to temporary files (`_tmp/` in the database folder) and adds them up after the scan. The result is the same, only slower. At most 16 GB of such files per query. |
| `SELECT` without `FROM` | Allowed: `SELECT 1 + 1`. |
### Views
```sql
CREATE [OR REPLACE] [CACHED] VIEW [IF NOT EXISTS] name AS [(] SELECT ... [)] [REFRESH ON COMMIT];
ALTER VIEW name AS SELECT ...;
REFRESH VIEW name;
DROP VIEW [IF EXISTS] name;
CREATE MATERIALIZED VIEW [IF NOT EXISTS] name [PARTITION BY DAY | MONTH | YEAR] [TTL n DAYS]
AS SELECT time_bucket, ..., aggregates FROM table GROUP BY time_bucket, ...;
REFRESH MATERIALIZED VIEW name [FOR 'time' [TO 'time']];
DROP MATERIALIZED VIEW [IF EXISTS] name;
SHOW VIEWS;
```
| Kind | What it keeps |
|---|---|
| View | Nothing. Its query runs every time you use it. |
| `CACHED` view | Its rows, in memory. Made at `CREATE` and at `REFRESH VIEW`; with `REFRESH ON COMMIT` again at the first read after a commit changed one of its tables. `CREATE` says `view 'name' created (4 row(s) cached)`. |
| `MATERIALIZED` view | A real table with the result of a `GROUP BY` over one time-series table. After each commit, a background thread rebuilds only the partitions that changed. The `GROUP BY` must have a time bucket: the time column, `to_date(time)` or `date_trunc('day', time)`. |
`REFRESH ON COMMIT` needs `CACHED`. A view and a table cannot have the same name.
### Indexes and full-text search
Chapter 17 explains them with a forum and a news example.
```sql
CREATE [UNIQUE | FULLTEXT] INDEX [IF NOT EXISTS] name ON table (column, ...);
CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] [name] ON [ONLY] table [USING method]
({column | (expression)} [DESC] [NULLS LAST], ...) [WHERE condition]; -- PostgreSQL form, chapter 22
DROP INDEX [IF EXISTS] name;
SHOW INDEXES [FROM table]; -- columns: name, table, kind, columns, built, rows, memory_bytes, create_sql
```
| Rule | Detail |
|---|---|
| Name | Unique in the database. |
| Columns | 1 to 16 stored columns (not computed). Relational and time-series tables. |
| `UNIQUE` | Two rows cannot have the same values. NULL values never clash. Error: `duplicate value violates unique index 'name' on table 't': a row with col='value' already exists`. Creating it fails when rows already clash. |
| `FULLTEXT` | STRING, SYMBOL or JSON columns. Used by `MATCH(...)`, `search_rank(...)`. |
| Where the data lives | In memory only. `kdb serve` builds it from the rows right after the database opens, on a background thread. In `kdb sql`, `kdb shell` and with `--warm-indexes off`, the first query that needs it builds it. `CREATE INDEX` builds it at once. Nothing on disk: crashes, `kdb check`, `kdb repair`, backups need nothing extra. |
| When a query uses an index | `col = value`, `col IN (...)`, `col IS NULL` on the first columns, then a range (`<`, `<=`, `>`, `>=`, `BETWEEN`, `LIKE 'abc%'`, `time IN '...'`, a row comparison) on the next column. `ORDER BY` the next columns (all ASC or all DESC) with `LIMIT` reads only the rows needed. `MATCH` uses a FULLTEXT index on the same columns. `EXPLAIN` shows `use index name (...)`. Also used by `UPDATE` and `DELETE`. |
| When it does not | A condition with `OR`, a function around the column, `!=`, `NOT LIKE`, a FLOAT column, text compared with a number, or when the index would read more than a quarter of the table. |
| `DROP COLUMN` | Refused while an index uses the column. `RENAME COLUMN` / `RENAME TABLE` keep the index. `DROP TABLE` drops its indexes. |
| Expression and partial | `(lower(email))`, `WHERE is_default`. A UNIQUE one is checked on every write. A non-unique one is used by a query that asks for the same thing: `lower(email) = 'a@x.np'`, or a `WHERE` that has every part of the index's own `WHERE`. |
| Not in a transaction | `CREATE INDEX` and `DROP INDEX` cannot be used between `BEGIN` and `COMMIT`. |
### Sequences
Chapter 22 explains them.
```sql
CREATE SEQUENCE [IF NOT EXISTS] name [INCREMENT [BY] n] [MINVALUE n] [MAXVALUE n] [START [WITH] n] [[NO] CYCLE];
ALTER SEQUENCE name [RESTART [WITH n]] [INCREMENT [BY] n] ...;
DROP SEQUENCE [IF EXISTS] name [, ...];
SELECT nextval('name'), currval('name'), setval('name', n [, is_called]), lastval();
```
A number is never given twice, also after a crash. `currval` and `lastval` are per session.
### PostgreSQL schema statements
`CREATE SCHEMA`, `CREATE EXTENSION` (pgcrypto, uuid-ossp, citext, plpgsql, pg_trgm, btree_gin, btree_gist, unaccent) and `COMMENT ON` are accepted and change nothing. `DO $$ BEGIN ... END $$` runs `IF`, `RAISE` and SQL statements. The views `information_schema.tables`, `.columns`, `.table_constraints`, `.key_column_usage`, ... and `pg_class`, `pg_attribute`, `pg_indexes`, `pg_constraint`, ... describe the database. Chapter 22.
### Functions, procedures and triggers
Chapter 13 explains them with examples.
```sql
CREATE [OR REPLACE] FUNCTION name(param TYPE, ...) [RETURNS TYPE] AS (expression);
DROP FUNCTION [IF EXISTS] name;
SHOW FUNCTIONS; -- columns: name, arguments, returns, definition
CREATE [OR REPLACE] PROCEDURE name(param TYPE, ...) AS BEGIN
statement; ...
END;
CALL name(argument, ...) [WITH DEBUG];
DROP PROCEDURE [IF EXISTS] name;
SHOW PROCEDURES; -- columns: name, arguments, definition
CREATE [OR REPLACE] TRIGGER name {BEFORE | AFTER} {INSERT | UPDATE | DELETE} ON table
EXECUTE {CALL procedure(argument, ...) | INSERT ... | UPDATE ... | DELETE ... | BEGIN statement; ... END};
ALTER TABLE table {ENABLE | DISABLE} TRIGGER name;
DROP TRIGGER [IF EXISTS] name;
CREATE [OR REPLACE] TRIGGER name AFTER INSERT ON table DEFERRED [TIMEOUT 10min] EXECUTE ...;
RETRY TRIGGER QUEUE name; -- a DEFERRED trigger: run the failed job again
SKIP TRIGGER JOB name job_id; -- a DEFERRED trigger: leave one job out
SHOW TRIGGERS; -- columns: name, table, timing, event, enabled, definition
```
Statements inside a procedure or trigger body (each ends with `;`):
```sql
SELECT ... | INSERT ... | UPDATE ... | DELETE ... | CALL name(...) | SHOW ... | EXPLAIN ...
DECLARE variable TYPE [DEFAULT expr]
SET variable = expr -- also: SET variable = (SELECT ...)
IF condition THEN ... [ELSIF condition THEN ...] [ELSE ...] END IF
WHILE condition DO ... END WHILE
FOR row IN (SELECT ...) DO ... END FOR -- row.column is a value of the current row
DECLARE cursor CURSOR FOR SELECT ...
OPEN cursor
FETCH cursor INTO variable, ... -- sets the variable FOUND
CLOSE cursor
RETURN
RAISE expr -- error: the whole CALL (or statement) is undone
PRINT expr -- a line in the "print" (or "debug") result
```
| Rule | Detail |
|---|---|
| Function body | One expression using only its parameters. No subqueries, aggregates or window functions. Not in computed columns. The name cannot be a built-in function. |
| Names in a body | A column name wins over a variable or parameter with the same name. |
| `CALL` | One transaction. Its SELECTs return extra results; `PRINT` adds a `print` result; `WITH DEBUG` adds a `debug` result (step, routine, statement, rows, time_us, changes). |
| Triggers | Once per statement (also once per `InsertRows` call and `POST /write` request), in the same transaction. The body reads the tables `NEW` and `OLD`. |
| `DEFERRED` | Only `AFTER INSERT`. Runs after the commit, as a background job of the server; the insert does not wait. Chapter 13. |
| `DROP TABLE`, `RENAME TABLE` | Refused while the table has triggers. |
### Transactions and cursors
```sql
BEGIN [TRANSACTION | WORK];
COMMIT [TRANSACTION | WORK];
ROLLBACK [TRANSACTION | WORK];
DECLARE name CURSOR FOR SELECT ...; -- runs the query and keeps the rows
FETCH [NEXT [n] | ALL] FROM name; -- NEXT without n = 1 row
CLOSE name;
```
| Rule | Detail |
|---|---|
| Scope | A transaction and a cursor end with the text they were sent in (`kdb sql`, `/exec`, the web console). In a session (`kdb shell`, `POST /session` + `X-KDB-Session`, a PostgreSQL connection) they stay open over many requests. A text without a session that ends without `COMMIT` is rolled back with an error. |
| Errors | A failing statement rolls back the whole open transaction. In a session, only `ROLLBACK` (or `COMMIT`, which then fails) is accepted after that. |
| Many writers | Writers of different tables run at the same time; writers of the same table take turns: they wait up to 5 s (`table 'x' is busy`), and two transactions that would wait for each other get `deadlock` at once. Readers see the committed rows. An idle transaction is rolled back after 30 s. Chapter 15. |
| Not in a transaction | `CREATE`, `DROP`, `ALTER`, `RENAME`, `TRUNCATE`, `BACKUP DATABASE`. |
| HTTP | `POST /cursor`, `GET /cursor/?fetch=N`, `DELETE /cursor/` (chapter 8). |
### Database
```sql
BACKUP DATABASE; -- needs --backups on kdb serve / shell / sql
CHECK TABLE any_name; -- reads every file of the whole database
```
`CHECK TABLE` checks **all** files; the table name is not used.
### Databases
A **database** is a folder in the **data folder** (chapter 1). Each database has its own tables, views, triggers and procedures.
```sql
CREATE DATABASE [IF NOT EXISTS] name;
DROP DATABASE [IF EXISTS] name; -- closes it and moves its folder to /_trash
SHOW DATABASES; -- columns: name, tables, bytes, current
USE name; -- the statements after it use this database
SELECT ... FROM other_db.table ...; -- read a table of another database on the same server
```
| Rule | Detail |
|---|---|
| Names | Letters, digits, `_` and `-`, 1 to 64 characters. `Market` and `market` are the same database. The folder keeps the name as you first wrote it. Not allowed: `_trash` and the Windows device names `CON`, `PRN`, `AUX`, `NUL`, `COM0`-`COM9`, `LPT0`-`LPT9`. |
| `DROP DATABASE` | Refused while another request or shell uses the database. Nothing is deleted: the folder moves to `_trash/-`. To get it back, stop the server and move the folder back. |
| `database.table` | Reads a table of another database: `SELECT`, joins, subqueries, `INSERT INTO t SELECT ...`, `CREATE TABLE t AS SELECT ...`. A statement only changes the database it runs in. Works over HTTP, the PostgreSQL port, `kdb shell` and the console. |
| `USE` | In `kdb shell`: stays until the next `USE`. Over HTTP: only for the rest of that request; choose the database of a request with `db=` (chapter 8). |
| Where they work | `kdb shell` and `kdb sql` with a database name (or no name), and `kdb serve` with a data folder. A command that opened one folder by its **path** (`kdb serve D:\kdb\market`) cannot create or drop databases. |
```powershell
@"
CREATE DATABASE IF NOT EXISTS market;
CREATE DATABASE demo;
USE demo;
CREATE TABLE notes (id INT PRIMARY KEY, text STRING);
SHOW DATABASES;
USE market;
DROP DATABASE demo;
DROP DATABASE IF EXISTS demo;
SHOW DATABASES;
"@ | kdb shell --data-dir kdb-data
```
```text
KDB 0.2.0-dev shell on default (data folder kdb-data)
End statements with ';'. Type .help for commands, .exit to quit.
kdb> database 'market' created
kdb> database 'demo' created
kdb> using database 'demo'
kdb> table 'notes' created
kdb> name | tables | bytes | current
--------+--------+-------+--------
default | 0 | 0 | false
demo | 1 | 229 | true
market | 0 | 0 | false
(3 rows, 0.1 ms)
kdb> using database 'market'
kdb> database 'demo' dropped; its folder was moved to kdb-data/_trash/1789135960212-demo
kdb> database 'demo' does not exist
kdb> name | tables | bytes | current
--------+--------+-------+--------
default | 0 | 0 | false
market | 0 | 0 | true
(2 rows, 0.0 ms)
kdb>
```
### Users and roles
Users of the KDB server (chapter 16). They belong to the data folder, not to one database.
```sql
CREATE USER [IF NOT EXISTS] name [WITH] PASSWORD 'secret' [ROLE admin|write|read|none];
ALTER USER name [WITH] PASSWORD 'new secret';
ALTER USER name ROLE read;
DROP USER [IF EXISTS] name;
SHOW USERS; -- columns: name, role, grants, created, changed
GRANT read|write|admin|ALL ON DATABASE db TO name; -- ALL = admin
REVOKE [read|write|admin|ALL] ON DATABASE db FROM name;
```
| Role | Statements |
|---|---|
| `read` | `SELECT`, `SHOW`, `EXPLAIN`, `BEGIN`/`COMMIT`/`ROLLBACK`, `DECLARE`/`FETCH`/`CLOSE` |
| `write` | + `INSERT`, `UPDATE`, `DELETE` (also with `RETURNING`), `TRUNCATE`, `CALL`, `SELECT ... FOR UPDATE` |
| `admin` | + `CREATE`/`ALTER`/`DROP`/`RENAME` of tables, views, indexes, functions, procedures, triggers; `BACKUP DATABASE`; `CHECK TABLE` |
| ROLE `admin` (own role) | + `CREATE`/`DROP DATABASE`, all user statements |
| Rule | Detail |
|---|---|
| Names | letters, digits, `_` and `-`, 1 to 63 characters; not case sensitive |
| Passwords | 8 to 1024 bytes. Shown as `'***'` in every statement KDB shows back. |
| First user | must have `ROLE admin`. From then on the server asks for a login. |
| Last admin | cannot be dropped or lose ROLE admin while other users exist |
| Own password | every user may run `ALTER USER WITH PASSWORD '...'` |
| `SHOW USERS` | never shows passwords or hashes; a user who is not an admin sees only their own row |
| `kdb shell`, `kdb sql` | open the files directly and do not check users |
## Types
| Type | Also written | Size | Range | NULL stored as |
|---|---|---|---|---|
| `BOOL` | `BOOLEAN` | 1 byte | `TRUE`, `FALSE` | special value |
| `BYTE` | | 1 byte | -127 to 127 | -128 |
| `SHORT` | | 2 bytes | -32767 to 32767 | -32768 |
| `INT` | `INTEGER` | 4 bytes | -2147483647 to 2147483647 | smallest value |
| `LONG` | `BIGINT` | 8 bytes | -9223372036854775807 to 9223372036854775807 | smallest value |
| `FLOAT` | | 4 bytes | about ±3.4e38, 7 digits | NaN |
| `DOUBLE` | | 8 bytes | about ±1.8e308, 15 digits | NaN |
| `DECIMAL(s)` | | 8 bytes | exact; value × 10^s must fit in LONG; s = 0..18 | smallest value |
| `DATE` | | 4 bytes | days since 1970-01-01 | smallest value |
| `TIMESTAMP` | | 8 bytes | milliseconds since 1970-01-01 UTC | smallest value |
| `SYMBOL` | | 4 bytes | repeated text | special value |
| `STRING` | `VARCHAR`, `TEXT` | length + text | UTF-8 text, at most 16 MB | special length |
| `JSON` | `JSONB` | length + text | checked JSON, stored without extra spaces, at most 16 MB | special length |
| `BYTES` | `BYTEA`, `BLOB` | length + bytes | any bytes (also zero bytes), at most 16 MB; written `X'0A0B'` or `'\x0a0b'`, shown as `\x0a0b` | special length |
Chapter 18 explains JSON and BYTES with examples.
PostgreSQL type names are accepted too, with their rules: `SMALLINT`, `SERIAL`/`BIGSERIAL` (the PRIMARY KEY with AUTO), `REAL`, `DOUBLE PRECISION`, `NUMERIC(p,s)` (DECIMAL(s), p digits checked), `NUMERIC` (DOUBLE), `MONEY`, `VARCHAR(n)`/`CHAR(n)` (STRING, at most n characters), `TIMESTAMPTZ` (TIMESTAMP), `TIME` (STRING `HH:MM:SS`), `INTERVAL` (LONG milliseconds), `UUID` (STRING, checked), `INET`, `CITEXT`, `type[]` (JSON array). The full table is in chapter 22.
### Conversions (when a value goes into a column, or with CAST)
| Into | Accepts |
|---|---|
| integers | whole numbers, whole DOUBLE values (`5.0`), text like `'42'`, `TRUE`/`FALSE`. Out of range → error. |
| DOUBLE, FLOAT | numbers, text like `'512.5'` |
| DECIMAL(s) | numbers and text with at most `s` digits after the point. More digits → error (use `round(x, s)`). |
| TIMESTAMP | time text (see time rules), DATE, a number (milliseconds) |
| DATE | `'2026-09-10'`, time text (the UTC date is kept), TIMESTAMP |
| BOOL | `TRUE`/`FALSE`, `1`/`0`, `'true'`/`'false'` |
| STRING, SYMBOL | anything (written as text) |
| JSON | JSON text (checked; bad JSON → error with the position), numbers, `TRUE`/`FALSE` |
| BYTES | `X'0A0B'`, text `'\x0a0b'` (hex), other text (its UTF-8 bytes) |
`CAST(x AS TYPE)` and `x::TYPE` are the same. `CAST(x AS DECIMAL(2))` needs the scale.
## Operators
From loose (evaluated last) to tight (evaluated first):
| Level | Operators |
|---|---|
| 1 | `OR` |
| 2 | `AND` |
| 3 | `NOT` |
| 4 | `=` `==` `!=` `<>` `<` `<=` `>` `>=`, `IS [NOT] NULL`, `[NOT] IN`, `[NOT] BETWEEN`, `[NOT] LIKE`, `[NOT] ILIKE` |
| 5 | `+` `-` `\|\|` |
| 6 | `*` `/` `%` |
| 7 | unary `-` |
| 8 | `::` cast, `->` `->>` (JSON), `( )`, values, columns, function calls, `CASE`, `CAST` |
```sql
SELECT 2 + 3 * 4 AS a, (2 + 3) * 4 AS b, -2 * 3 AS c, 1 = 1 OR 1 = 2 AND 1 = 3 AS d, NOT 1 = 2 AND 2 = 2 AS e;
```
```text
a,b,c,d,e
14,20,-6,true,true
```
| Operator rule | Example | Result |
|---|---|---|
| `/` always gives a decimal number | `7 / 2` | `3.5` |
| Division or `%` by zero gives NULL | `5 / 0` | NULL |
| Integer overflow switches to DOUBLE | | |
| TIMESTAMP ± number = milliseconds added | `CAST('2026-09-10T11:00:00+05:45' AS TIMESTAMP) + 60000` | `2026-09-10T05:16:00.000Z` |
| TIMESTAMP - TIMESTAMP = milliseconds | `'2026-09-10T11:05:00Z'::TIMESTAMP - '2026-09-10T11:00:00Z'::TIMESTAMP` | `300000` |
| DATE ± number = days added | | |
| Text compared with TIMESTAMP/DATE is read as a time | `time >= '2026-09-01'` | |
| Text is joined with `\|\|` (not `+`); NULL on either side gives NULL | `'CITY' \|\| '-' \|\| 1` | `CITY-1` |
| `LIKE`: `%` any text, `_` one character (also one letter of any language), `\` before `%` or `_` means the letter itself, case matters | `'COAST' LIKE 'C_AST'` | `true` |
| `ILIKE`: like `LIKE`, but English letters match in any case | `'City' ILIKE 'CIT%'` | `true` |
| `json -> 'key'`, `json -> 0`: a part of a JSON value (as JSON); `->>` gives it as text | `'{"a": [5]}' -> 'a' ->> 0` | `5` |
| Row comparison: `(a, b) < (x, y)` means `a < x OR (a = x AND b < y)`; also `<=` `>` `>=` `=` `!=`, any number of values | `(2, 5) < (2, 7)` | `true` |
### NULL and logic
```sql
SELECT coalesce(NULL, 'n/a') AS coalesce, nullif(5, 5) AS nullif, NULL + 1 AS null_plus, NULL = NULL AS null_eq,
NULL IS NULL AS is_null, TRUE AND NULL AS and_null, TRUE OR NULL AS or_null, NOT FALSE AS not_false,
3 BETWEEN 1 AND 5 AS between_1_5, 'COAST' IN ('CITY', 'COAST') AS in_list, 1 <> 2 AS not_equal;
```
```text
coalesce,nullif,null_plus,null_eq,is_null,and_null,or_null,not_false,between_1_5,in_list,not_equal
n/a,,,,true,,true,true,true,true,true
```
(Empty CSV fields are NULL.) NULL in arithmetic or comparison gives NULL. WHERE and HAVING treat NULL as false. Use `IS NULL`.
## Functions
### Math
| Function | Meaning | Example | Result |
|---|---|---|---|
| `abs(x)` | absolute value | `abs(-3.5)` | `3.5` |
| `round(x)`, `round(x, d)` | round (half away from zero) to d decimals | `round(512.456, 2)`, `round(512.5)` | `512.46`, `513` |
| `floor(x)` | round down | `floor(5.7)` | `5` |
| `ceil(x)`, `ceiling(x)` | round up | `ceil(5.2)` | `6` |
| `sqrt(x)` | square root (NULL for negative) | `sqrt(16)` | `4` |
| `pow(x, y)`, `power(x, y)` | x to the power y | `pow(2, 10)` | `1024` |
| `exp(x)` | e to the power x | `exp(0)` | `1` |
| `ln(x)`, `log(x)` | natural log (NULL for ≤ 0) | `ln(1)` | `0` |
| `log(base, x)` | log with a base | `log(2, 8)` | `3` |
| `log10(x)` | log base 10 | `log10(1000)` | `3` |
| `sign(x)` | -1, 0 or 1 | `sign(-7)` | `-1` |
| `mod(x, y)` | remainder (same as `%`) | `mod(10, 3)` | `1` |
| `greatest(a, b, ...)` | biggest (NULLs ignored) | `greatest(3, 9, 4)` | `9` |
| `least(a, b, ...)` | smallest (NULLs ignored) | `least(3, 9, 4)` | `3` |
More math, finance and price functions (`trunc`, `cbrt`, `log2`, `gcd`, `lcm`, `factorial`, `pi`, trigonometry, `width_bucket`, `clamp`, `safe_divide`, `div0`, `percent`, `random`, `setseed`, `normal_cdf`, `normal_inv`, `round_tick`, `pivot_point`, `pivot_level`, `cagr`, `fv`, `pv`, `pmt`, `nper`, `npv`, `irr`) are in [chapter 19](19-analysis-functions.md#part-5-scalar-functions).
### Text
| Function | Meaning | Example | Result |
|---|---|---|---|
Text functions count **characters**, so text in any language works (`length('नेपाल')` is `5`). A NULL argument gives NULL (except `concat`, `concat_ws`, `coalesce`).
| Function | Meaning | Example | Result |
|---|---|---|---|
| `upper(s)` | upper case (English letters; other letters stay) | `upper('city')` | `CITY` |
| `lower(s)` | lower case | `lower('CITY')` | `city` |
| `length(s)`, `char_length(s)` | number of characters (BYTES: number of bytes) | `length('नेपाल')` | `5` |
| `octet_length(s)` | number of bytes | `octet_length('नेपाल')` | `15` |
| `substr(s, start [, len])`, `substring(...)` | part of the text; start counts from 1 | `substr('CITY-1', 1, 4)` | `CITY` |
| `left(s, n)`, `right(s, n)` | first / last n characters (negative n: all but n) | `left('CITY', 2)` | `CI` |
| `trim(s [, chars])`, `ltrim`, `rtrim`, `btrim` | remove spaces (or the given characters) at the ends | `trim('xxNICAxx', 'x')` | `COAST` |
| `replace(s, from, to)` | replace all | `replace('CITY-1', '-1', '')` | `CITY` |
| `split_part(s, sep, n)` | field n (1 = first, -1 = last) | `split_part('2082/83', '/', 2)` | `83` |
| `strpos(s, part)`, `position(part IN s)` | where part starts (1 = first character), 0 = not found | `strpos('CITY', 'TY')` | `3` |
| `starts_with(s, p)`, `ends_with(s, p)` | true if s starts / ends with p | `starts_with('CITY-1', 'CIT')` | `true` |
| `lpad(s, n [, fill])`, `rpad(...)` | fill to n characters (cut if longer) | `lpad('7', 3, '0')` | `007` |
| `repeat(s, n)`, `reverse(s)` | repeat n times; characters backwards | `repeat('ab', 2)` | `abab` |
| `concat(a, b, ...)` | join as text (NULLs skipped) | `concat('CITY', '-', 1)` | `CITY-1` |
| `concat_ws(sep, a, b, ...)` | join with a separator (NULLs skipped) | `concat_ws(', ', 'a', NULL, 'b')` | `a, b` |
| `regexp_like(s, pattern [, 'i'])` | true when the regular expression matches (`'i'`: any case) | `regexp_like('CITY', '^C[A-Z]+$')` | `true` |
| `regexp_substr(s, pattern [, 'i'])` | the first match, or NULL | `regexp_substr('EPS 12.50', '\d+\.\d+')` | `12.50` |
| `regexp_count(s, pattern [, 'i'])` | number of matches | `regexp_count('a1b22', '\d+')` | `2` |
| `regexp_replace(s, pattern, new [, flags])` | replace the first match; flag `g` = every match; `\1` in new = group 1 | `regexp_replace('a1b2', '\d', '#', 'g')` | `a#b#` |
| `coalesce(a, b, ...)` | first value that is not NULL | `coalesce(NULL, 'n/a')` | `n/a` |
| `nullif(a, b)` | NULL if a = b, else a | `nullif(5, 5)` | NULL |
Regular expressions: `.` `[abc]` `[^abc]` `[a-z]` `\d` `\w` `\s` `\b` `^` `$` `(...)` `(?:...)` `a|b` `*` `+` `?` `{n,m}` (and lazy `*?`). `\w` also matches letters of other languages. Back references and look-ahead are not supported. KDB never backtracks, so no pattern can hang the server.
### JSON and BYTES
Chapter 18 has examples. A path looks like `'$.a.b[0]'`, `'$.items[-1]'` (last) or `'$."key with spaces"'`.
| Function | Meaning |
|---|---|
| `json(text)`, `CAST(text AS JSON)` | check text and make it JSON |
| `json_valid(text)` | true when the text is valid JSON |
| `json_extract(j, path)` | the part at path, as JSON (NULL if missing) |
| `json_value(j, path)` | the part as a value: text, LONG, DOUBLE or BOOL (NULL for objects, arrays, null) |
| `json_value_text` / `json_value_double` / `json_value_long` / `json_value_bool(j, path)` | the part as that type (NULL if it does not fit) |
| `json_type(j [, path])` | `object`, `array`, `string`, `number`, `boolean`, `null` |
| `json_array_length(j [, path])`, `json_keys(j [, path])` | number of elements; the keys as a JSON array |
| `json_object(k, v, ...)`, `json_array(v, ...)`, `to_json(v)` | build JSON from values |
| `json_set(j, path, v, ...)`, `json_insert`, `json_replace`, `json_remove(j, path, ...)`, `json_array_append(j, path, v)` | change JSON (for UPDATE) |
| `hex(x)`, `unhex(text)` | bytes to hex text and back |
| `to_base64(x)`, `from_base64(text)`, `encode(bytes, 'hex'\|'base64')`, `decode(text, 'hex'\|'base64')` | base64 and hex |
### Full-text search
Chapter 17 has examples. They work with or without a FULLTEXT index (without one, every row is read).
| Function | Meaning |
|---|---|
| `MATCH(col, ..., 'words')` | true when the text of the columns has every word. `'"exact phrase"'` = words next to each other; `'hydro*'` = a word that starts with hydro; `'-loan'` = rows with this word are left out |
| `search_rank(col, ..., 'words')` | how well the row matches (BM25; higher is better, 0 when it does not match) |
| `highlight(col, ..., 'words')` | the text with the found words in `` and ` ` |
Words: letters and digits of any language (English, Nepali). English letters are made lower case, Devanagari digits become 0-9, punctuation (also `।` and `॥`) separates words, and a number like `12.5` stays one word.
### Time
All of these work in **UTC**. Arguments can be TIMESTAMP, DATE or time text.
| Function | Meaning | Example | Result |
|---|---|---|---|
| `now()`, `systimestamp()` | current time (the same for the whole statement) | `now()` | `2026-09-11T08:45:59.939Z` |
| `year(t)` `month(t)` `day(t)` | parts of the UTC date | `year('2026-09-10T08:30:15Z')` | `2026` |
| `hour(t)` `minute(t)` `second(t)` | parts of the UTC time | `hour('2026-09-10T08:30:15Z')` | `8` |
| `day_of_week(t)` | 0 = Sunday ... 6 = Saturday | `day_of_week('2026-09-11')` | `5` |
| `is_trading_day(t)` | true for Sunday to Thursday (UTC date; holidays are not known) | `is_trading_day('2026-09-11')` | `false` |
| `date_trunc(unit, t)` | cut down to `year`, `quarter`, `month`, `week` (Sunday), `day`, `hour`, `minute`, `second`, `ms` | `date_trunc('week', '2026-09-10T08:30:00Z')` | `2026-09-06T00:00:00.000Z` |
| `extract(field FROM t)`, `date_part('field', t)` | `year`, `quarter`, `month`, `week`, `day`, `dow`, `isodow`, `doy`, `hour`, `minute`, `second`, `epoch`, ... | `extract(dow FROM '2026-09-11')` | `5` |
| `dateadd(unit, n, t)` | add n units: `y`, `month`, `w`, `d`, `h`, `m`, `s`, `ms` (day clamped at month end) | `dateadd('month', 1, '2026-01-31')` | `2026-02-28T00:00:00.000Z` |
| `datediff(unit, a, b)` | whole units from a to b: `y`, `month`, `w`, `d`, `h`, `m`, `s`, `ms` (months and years by the calendar) | `datediff('m', '2026-09-10T11:00:00+05:45', '2026-09-10T15:00:00+05:45')` | `240` |
| `to_timestamp(x)` | text or number (ms) to TIMESTAMP | `to_timestamp('2026-09-10T11:00:00+05:45')` | `2026-09-10T05:15:00.000Z` |
| `to_date(x)` | to DATE (UTC date) | `to_date('2026-09-10T20:00:00Z')` | `2026-09-10` |
| `to_char(t, format [, zone])` | time as text: `YYYY` `YY` `MM` `Mon` `Month` `DD` `DDD` `Dy` `Day` `D` `HH24` `HH12` `MI` `SS` `MS` `AM` `Q` (quarter), `"text"`; zone like `'+05:45'` or `'Asia/Kathmandu'` | `to_char('2026-09-10T08:30:00Z', 'YYYY-MM-DD HH24:MI', '+05:45')` | `2026-09-10 14:15` |
Nepal clock: `hour(time + 20700000)` (20700000 ms = 5 h 45 min).
Also in [chapter 19](19-analysis-functions.md#dates-for-reports): `age`, `quarter`, `day_of_year`, `week_of_year`, `last_day`, `make_date`, `make_timestamp`, `fiscal_year`, `fiscal_quarter`, `nepal_fiscal_year`.
### Aggregates
Used in SELECT with GROUP BY, SAMPLE BY or alone; also with `OVER (...)`.
| Function | Meaning |
|---|---|
| `count(*)` | number of rows |
| `count(x)` | rows where x is not NULL |
| `count(DISTINCT x)`, `count_distinct(x)` | number of different values |
| `sum(x)` | total (stays integer or DECIMAL when the inputs are) |
| `avg(x)` | average |
| `min(x)`, `max(x)` | smallest, biggest (numbers, text, times) |
| `first(x)`, `last(x)` | value of the earliest / latest row by time |
| `median(x)` | middle value |
| `percentile(x, p)` | p = 0.9 or 90 |
| `stddev(x)`, `stddev_samp(x)` | sample standard deviation |
| `stddev_pop(x)` | population standard deviation |
| `variance(x)`, `var_samp(x)`, `var_pop(x)` | variance |
| `vwap(price, volume)` | sum(price × volume) / sum(volume) |
```sql
SELECT count(*) AS count_all, count(close) AS count_close, count(DISTINCT symbol) AS count_distinct,
count_distinct(symbol) AS count_distinct2, sum(volume) AS sum, round(avg(close), 3) AS avg,
min(close) AS min, max(close) AS max, first(close) AS first, last(close) AS last
FROM ochl_day WHERE time IN '2026-09-10';
SELECT round(stddev(close), 3) AS stddev, round(stddev_pop(close), 3) AS stddev_pop, round(variance(close), 3) AS variance,
round(var_pop(close), 3) AS var_pop, median(close) AS median, percentile(close, 0.9) AS p90,
round(vwap(close, volume), 3) AS vwap
FROM ochl_day WHERE time IN '2026-09-10';
```
```text
count_all,count_close,count_distinct,count_distinct2,sum,avg,min,max,first,last
3,3,3,3,410710,357.4,191.2,504.9,504.9,191.2
stddev,stddev_pop,variance,var_pop,median,p90,vwap
157.684,128.748,24864.19,16576.127,376.1,479.14,414.832
```
Aggregates ignore NULL values. `count()` without `*` is the same as `count(*)`.
More aggregates are in [chapter 19](19-analysis-functions.md#part-1-aggregates) (row engine only):
| Group | Functions |
|---|---|
| Correlation, regression | `corr`, `covar_pop`, `covar_samp`, `regr_slope`, `regr_intercept`, `regr_r2`, `regr_count`, `regr_avgx`, `regr_avgy`, `regr_sxx`, `regr_syy`, `regr_sxy`, `beta`, `alpha` |
| Shape | `skewness` (`skew`), `skewness_pop`, `kurtosis` (`kurt`), `kurtosis_pop`, `geomean`, `harmonic_mean`, `mode`, `percentile_cont`, `percentile_disc` (also `... WITHIN GROUP (ORDER BY x)`), `median_abs_deviation` (`mad`), `iqr`, `range`, `entropy` |
| Conditions, picking rows | `count_if`, `sum_if`, `avg_if`, `bool_and` (`every`), `bool_or`, `arg_min` (`min_by`), `arg_max` (`max_by`), `approx_count_distinct`, `sum_of_squares`, `product`, `weighted_avg`, `twap` |
| Lists | `string_agg`, `listagg`, `array_agg` (`json_agg`) |
| Risk, money | `sharpe_ratio`, `sortino_ratio`, `calmar_ratio`, `information_ratio`, `max_drawdown`, `value_at_risk`, `expected_shortfall`, `xnpv`, `xirr` |
### Series and window functions
Used in SELECT only. Without `OVER`, series functions run over all result rows in time order.
| Function | Meaning |
|---|---|
| `sma(x, n)` | simple moving average of n rows |
| `ema(x, n)` | exponential moving average (starts with SMA of the first n rows) |
| `rsi(x, n)` | Wilder's RSI (0-100) |
| `atr(high, low, close, n)` | Wilder's average true range |
| `lag(x [, k [, default]])` | value k rows before (default 1); `default` instead of NULL when there is no such row |
| `lead(x [, k [, default]])` | value k rows after |
| `pct_change(x)` | (x - previous) / previous × 100 |
| `cumsum(x)` | running total |
| `row_number()` | 1, 2, 3, ... |
| `rank()` | rank with gaps (1, 1, 3) |
| `dense_rank()` | rank without gaps (1, 1, 2) |
| any aggregate `OVER (...)` | aggregate over a window |
```sql
fn(...) OVER ([PARTITION BY expr, ...] [ORDER BY expr [ASC|DESC], ...] [frame])
frame: ROWS n PRECEDING | ROWS UNBOUNDED PRECEDING
| {ROWS | RANGE} BETWEEN start AND end
start, end: UNBOUNDED PRECEDING | n PRECEDING | CURRENT ROW | n FOLLOWING | UNBOUNDED FOLLOWING
(RANGE: n is a number, or text like '5 days' for a time ORDER BY)
```
The period `n` must be a whole number, 1 or more.
More window and series functions are in [chapter 19](19-analysis-functions.md): `percent_rank`, `cume_dist`, `ntile`, `first_value`, `last_value`, `nth_value`, `cumprod`, `cummax`, `cummin`, returns and risk (`pct_return`, `log_return`, `cum_return`, `drawdown`, `volatility`, `zscore`, `minmax_scale`) and technical indicators (`wma`, `dema`, `tema`, `trima`, `kama`, `hma`, `vwma`, `macd`, `macd_signal`, `macd_hist`, `stoch_k`, `stoch_d`, `stochf_k`, `stochrsi_k`, `stochrsi_d`, `willr`, `cci`, `roc`, `rocp`, `rocr`, `mom`, `trix`, `natr`, `trange`, `bb_upper`, `bb_middle`, `bb_lower`, `bb_percent_b`, `bb_width`, `kc_upper`, `kc_middle`, `kc_lower`, `dc_upper`, `dc_middle`, `dc_lower`, `plus_di`, `minus_di`, `dx`, `adx`, `adxr`, `aroon_up`, `aroon_down`, `aroon_osc`, `sar`, `ichimoku_tenkan`, `ichimoku_kijun`, `ichimoku_senkou_a`, `ichimoku_senkou_b`, `ichimoku_chikou`, `obv`, `ad`, `cmf`, `adosc`, `mfi`, `efi`, `eom`, `supertrend`, `supertrend_direction`, `ha_open`, `ha_high`, `ha_low`, `ha_close`, `linearreg`, `linearreg_slope`, `linearreg_intercept`, `linearreg_angle`, `tsf`).
### Table functions and PIVOT
```sql
SELECT ... FROM generate_series(start, stop [, step]) [[AS] alias [(column)]] -- numbers or times
SELECT ... FROM long_sequence(n) -- column x = 1 .. n
SELECT ... FROM source PIVOT (agg(x) [AS name], ... FOR column IN (value [AS name], ...)) [[AS] alias]
```
Chapter 19 explains them ([Part 6](19-analysis-functions.md#part-6-tables-of-numbers-and-pivots)).
### Passwords, hashes and random values
| Function | Meaning | Example | Result |
|---|---|---|---|
| `password_hash(s)` | slow, salted hash for storing a password (PBKDF2-HMAC-SHA-256, 600,000 rounds) | `password_hash('secret')` | `$pbkdf2-sha256$i=600000$...` |
| `password_hash(s, n)` | the same with n rounds (1,000 to 10,000,000) | `password_hash('secret', 1000)` | `$pbkdf2-sha256$i=1000$...` |
| `password_verify(s, hash)` | does the password match the hash? (`false` for a damaged hash) | `password_verify('secret', password_hash)` | `true` |
| `sha256(s)` | SHA-256 as 64 hex characters | `sha256('abc')` | `ba7816bf...f20015ad` |
| `hmac_sha256(key, s)` | HMAC-SHA-256 as hex | `hmac_sha256('key', 'message')` | `6e9ef29b...` |
| `random_bytes(n)` | n random bytes (1 to 1024) as hex | `random_bytes(8)` | `6614740117d2ce39` |
| `random_token([n])` | n random bytes (default 32) as URL-safe base64 | `random_token()` | 43 characters |
| `gen_random_uuid()` | random UUID (version 4) | `gen_random_uuid()` | `0465d47f-8194-4c94-a372-eda3b70504a0` |
A NULL argument gives NULL. The random functions give a new value for every row and every call. `password_hash` and `password_verify` take about 0.05 to 0.4 seconds each: call `password_verify` once per login. `password_hash('...')` with a literal password in `INSERT ... VALUES` and `UPDATE ... SET` is hashed before the statement waits for the database, and the statement shown back contains the hash, not the password. Chapter 16 shows the register and login pattern.
### Other expressions
| Expression | Example |
|---|---|
| `CASE WHEN cond THEN a [WHEN ...] [ELSE b] END` | `CASE WHEN pct_change > 0 THEN 'up' ELSE 'down' END` |
| `CASE x WHEN v THEN a ... END` | `CASE symbol WHEN 'CITY' THEN 1 ELSE 0 END` |
| `CAST(x AS TYPE)`, `x::TYPE` | `CAST('512.5' AS DOUBLE)`, `'42'::INT` |
| `x [NOT] IN (a, b, ...)` | `symbol IN ('CITY', 'COAST')` |
| `t [NOT] IN 'interval'` | `time IN '2026-09'` |
| `x [NOT] BETWEEN a AND b` | `close BETWEEN 500 AND 510` |
| `EXISTS (SELECT ...)` | |
| `(SELECT ...)` | a single value |
## Time rules
| Rule | Detail |
|---|---|
| Storage | TIMESTAMP = milliseconds since 1970-01-01 **UTC**. DATE = days. |
| Output | Always UTC: `2026-09-10T05:15:00.000Z`, DATE as `2026-09-10`. |
| Time text | `YYYY-MM-DD`, then optional `T` or space and `HH`, `:MM`, `:SS`, `.fff`, then optional `Z` or `+HH:MM` / `+HHMM` / `-HH:MM`. |
| No zone | **UTC**. Nepal time must have `+05:45`. |
| More than 3 fraction digits | cut to milliseconds |
| Numbers | milliseconds since 1970 (UTC) |
| `time IN 'text'` | The whole year / month / day / hour / minute / second of the text, in UTC. |
| Partitions | by UTC date |
| `SAMPLE BY ... TIME ZONE` | the only place where KDB computes in another time zone |
| `--until` of `kdb restore` | local time of the computer |
| Backup folder names | UTC |
Time zones for `TIME ZONE` (fixed offsets only, no daylight saving): `UTC`, `GMT`, `Z`, `Etc/UTC`, `Asia/Kathmandu`, `Asia/Katmandu`, `NPT`, `Asia/Kolkata`, `Asia/Calcutta`, `IST`, `Asia/Colombo`, `Asia/Dhaka`, `Asia/Thimphu`, `Asia/Karachi`, `Asia/Dubai`, `Asia/Bangkok`, `Asia/Jakarta`, `Asia/Shanghai`, `Asia/Hong_Kong`, `Asia/Singapore`, `Asia/Taipei`, `Asia/Tokyo`, `Asia/Seoul`, or an offset like `'+05:45'`. Others are refused:
```sql
SELECT time, count(*) FROM ochl_minutes SAMPLE BY 1h TIME ZONE 'Europe/London';
```
```text
error: time zone 'Europe/London' is not supported (it may change for daylight saving); use a fixed offset like '+05:45'
```
## Reserved words
These words cannot be table, column or alias names without double quotes:
`ALTER` `AND` `AS` `BETWEEN` `CASE` `CREATE` `DEFAULT` `DELETE` `DISTINCT` `DROP` `ELSE` `END` `FALSE` `FROM` `GROUP` `IN` `INSERT` `INTO` `IS` `JOIN` `LIKE` `LIMIT` `NOT` `NULL` `ON` `OR` `ORDER` `PRIMARY` `REFERENCES` `SELECT` `SET` `TABLE` `THEN` `TO` `TRUE` `UNIQUE` `UPDATE` `VALUES` `WHEN` `WHERE`
```sql
CREATE TABLE orders (id INT, order STRING);
```
```text
error: line 1, column 30: expected a column name but found the reserved word 'order' (write it in double quotes, "order", to use it as a name)
```
```sql
CREATE TABLE orders (id INT, "order" STRING);
INSERT INTO orders VALUES (1, 'buy');
SELECT id, "order" FROM orders;
```
```text
table 'orders' created
1 row(s) inserted
id | order
---+------
1 | buy
(1 row, 0.1 ms)
```
These words **can** be names, but not a short alias without `AS` (write `AS left` then): `ASOF` `CROSS` `FULL` `HAVING` `INNER` `LATEST` `LEFT` `LT` `OVER` `REFRESH` `RIGHT` `SAMPLE` `SPLICE` `UNION` `USING`.
All other words (`time`, `date`, `timestamp`, `symbol`, `partition`, `open`, `close`, `volume`, `value`, `key`, ...) are normal names.
## Limits
| Limit | Value |
|---|---|
| DECIMAL scale | 0 to 18 |
| Nesting of expressions | 100 levels |
| Nesting of subqueries and views | 16 levels |
| Nesting of saved functions, of `CALL`, of triggers | 16 levels each |
| Rows changed in one transaction (`BEGIN`/`COMMIT`, `CALL`, statement with triggers) | 10 million |
| Rounds of one `WHILE` loop | 10 million |
| Open cursors | 16 per database; unused ones close after 10 minutes; 10 million rows each |
| Tables in one query | 63 |
| Rows made by FILL | 10 million |
| Rows returned by `kdb sql` | 1000 in table format (`--max-rows`), no limit in csv/json |
| Rows returned by HTTP `/exec` | 10,000 by default, 1,000,000 at most |
| HTTP request body | 64 MB |
| One STRING, JSON or BYTES value | 16 MB (keep bigger files outside the database) |
| JSON nesting | 256 levels |
| Regular expression | 4096 bytes; `{n,m}` up to 1000 |
| Writers at the same time | one per table; writers of different tables run together |
| Readers at the same time | any number; a read never waits for an open transaction |
| Temporary files of one big GROUP BY | 16 GB |
| Programs with the database open | one |
| Number of partitions, rows, tables | no fixed limit |
## Correlated subqueries
```sql
SELECT s.symbol, (SELECT max(close) FROM ochl_day d WHERE d.symbol = s.symbol) AS best FROM securities s;
```
The subquery uses `s.symbol` of the outer row. KDB does not run it once per row: it reads `ochl_day` once, groups it by `symbol`, and looks every security up in that result. Chapter 23 has the rules.
## Try it yourself
1. What is the result of `SELECT 10 / 4, 10 % 4, round(10 / 4)`?
2. Write the number of whole days between 2026-01-01 and 2026-09-10.
3. Which of these names need double quotes: `time`, `order`, `left`, `select`, `symbol`?
Answers
1. `2.5`, `2`, `3`.
2. `SELECT datediff('d', '2026-01-01', '2026-09-10')` gives `252`.
3. `order` and `select` always. `left` only as a short alias without `AS`. `time` and `symbol` never.
---
Next: [12. Troubleshooting](12-troubleshooting.md)
---
# 12. Troubleshooting
- Category: Tutorial
## What you will learn
- The common error messages, exactly as KDB prints them.
- What each one means and how to fix it.
- Answers to frequent questions.
All messages below are real output (`examples/12-troubleshooting/`). Use `Ctrl+F` and search for words from your error.
## Quick table
| Message starts with | Section |
|---|---|
| `the database is open in another program` | [Database is locked](#database-is-locked) |
| `a row with ... already exists` | [Duplicate key](#duplicate-key) |
| `unknown column` | [Unknown column](#unknown-column) |
| `table or view ... does not exist` | [Unknown table](#unknown-table) |
| `expected ... but found ...` | [Syntax error](#syntax-error) |
| `is not a time like` / `cannot compare TIMESTAMP` | [Time format](#time-format) |
| `has more than 2 decimal places` | [Decimal places](#decimal-places) |
| `has no matching row in` | [REFERENCES failure](#references-failure) |
| `cannot remove ... still refers to it` | [Cannot delete a referenced row](#cannot-delete-a-referenced-row) |
| `cannot be NULL` / `needs a value` | [Missing value](#missing-value) |
| `is out of range` | [Number out of range](#number-out-of-range) |
| `cannot compare STRING ... with LONG` | [Type mismatch](#type-mismatch) |
| `port ... is already in use` | [Port in use](#port-in-use) |
| `access token required` / `wrong access token` (HTTP 401) | [Token 401](#token-401) |
| `cross-site requests are not allowed` (HTTP 403) | [403 cross-site](#403-cross-site-cross-origin-host-name) |
| `this SQL changes data, so it must be sent with POST` (HTTP 405) | [405 GET changes data](#405-get-changes-data) |
| `????` or broken letters instead of non-English text | [Text in other languages looks broken](#text-in-other-languages-looks-broken) |
| `failed its checksum`, `DAMAGED` | [Damage](#damage) |
## Database is locked
```text
error: Locked: the database is open in another program (for example kdb serve). Stop it, or query it through that server's console or HTTP API (kdb serve)
exit code: 1
error: the database is open in another program. Stop it first, or run BACKUP DATABASE in that server's console or API.
exit code: 1
```
(The first line is from `kdb sql`, the second from `kdb backup`, while `kdb serve` had the database open.)
**Why:** only one program can open a database folder. `kdb serve`, `kdb shell` or another `kdb sql` has it open.
**Fix:**
- Use the running server: the console, `POST /exec`, `POST /api/backup`, `POST /api/check` (chapter 8).
- Or stop the other program (Ctrl+C in its window), then run your command.
- Look for a forgotten `kdb shell` in another window. In PowerShell: `Get-Process kdb`.
- Do not delete `kdb.lock`. The lock is held by the running program, not by the file. When no program runs, the lock is free.
## Duplicate key
```sql
INSERT INTO ochl_day VALUES ('2026-09-10', 'CITY', 504.6, 507, 500.7, 504.9, 183040);
```
```text
error: a row with symbol='CITY', time='2026-09-10T00:00:00.000Z' already exists in 'ochl_day'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it
```
**Why:** the table has a DEDUP KEY or PRIMARY KEY, and a row with these values is already there (or twice in your statement). **Nothing** from this INSERT was saved.
**Fix:** `INSERT OR REPLACE` (new data wins) or `INSERT OR IGNORE` (old data wins). If you did not expect a duplicate: check the time zone (`+05:45` missing?) and `ROUND` on the time column (chapter 3).
## Unknown column
```sql
SELECT time, symbol, clsoe
FROM ochl_day
WHERE symbol = 'CITY';
```
```text
error: line 1, column 22: unknown column 'clsoe' (table 'ochl_day' has: time symbol open high low close volume)
```
**Why:** a typing mistake, or the column is in another table.
**Fix:** look at the list in the message. With joins, write `table.column`. In the console, click the arrow next to a table to see its columns.
`line 1, column 22` is the place in the SQL you sent. In a file, it is the line of the file.
## Unknown table
```sql
SELECT * FROM ochl_days;
```
```text
error: line 1, column 15: table or view 'ochl_days' does not exist
```
**Fix:** `SHOW TABLES` or `.tables` in the shell. Check that you opened the right database **folder**: `kdb sql` creates a new empty database when the folder does not exist, so a wrong folder name gives "does not exist" for every table.
## Syntax error
```sql
SELECT symbol close FROM ochl_day WHERE;
```
```text
error: line 1, column 40: expected an expression (a value, column name, function call or '(') but found ';'
```
**Why:** the SQL does not follow the grammar. The message says what KDB expected at that place.
Watch out: `SELECT symbol close` is **not** an error. It means "column symbol, named close". A missing comma changes your column names quietly.
An unclosed text:
```sql
SELECT * FROM brokers WHERE name = 'Northgate;
```
```text
error: line 1, column 36: expected a closing ' to end the string that starts here but found the end of the input
```
A reserved word as a name: see chapter 11 (`"order"`).
## Time format
```sql
INSERT INTO ochl_day VALUES ('10/09/2026', 'CITY', 1, 1, 1, 1, 1);
```
```text
error: column 'time' (TIMESTAMP): '10/09/2026' is not a time like '2026-09-11 11:00:00' (UTC) or '2026-09-11T11:00:00+05:45'
```
```sql
SELECT time, close FROM ochl_day WHERE time >= '2026-9-1';
```
```text
error: line 1, column 45: cannot compare TIMESTAMP '2025-12-28T00:00:00.000Z' with STRING '2026-9-1'
```
**Fix:** always `YYYY-MM-DD` with two digits for month and day: `'2026-09-01'`. Add `+05:45` for Nepal times. Convert other formats in your script before sending (for example in PowerShell: `([datetime]::ParseExact('10/09/2026', 'dd/MM/yyyy', $null)).ToString('yyyy-MM-dd')`).
**Times are 5:45 hours wrong?** You wrote Nepal time without `+05:45`. KDB read it as UTC. Fix the data with an UPDATE: `UPDATE t SET time = time - 20700000 WHERE ...` (this subtracts 5 h 45 min), but check the DEDUP KEY first.
## Decimal places
```sql
CREATE TABLE wallet (id INT PRIMARY KEY, balance DECIMAL(2));
INSERT INTO wallet VALUES (1, 100.50);
UPDATE wallet SET balance = balance * 1.015 WHERE id = 1;
```
```text
table 'wallet' created
1 row(s) inserted
error: column 'balance' (DECIMAL(2)): 102.0075 has more than 2 decimal places
```
**Why:** KDB never rounds money by itself.
**Fix:** round it yourself, the way your business rule says:
```sql
UPDATE wallet SET balance = round(balance * 1.015, 2) WHERE id = 1;
SELECT id, balance FROM wallet;
```
```text
1 row(s) updated
id | balance
---+--------
1 | 102.01
(1 row, 0.1 ms)
```
For amounts with more than about 15 digits, send the value as **text** (`'123456789012345.67'`). A number that big cannot be stored exactly as a DOUBLE, so KDB refuses it and tells you to write it as text.
## REFERENCES failure
```sql
INSERT INTO trade_log (date, transaction_id, symbol, buyer, seller, quantity, rate)
VALUES ('2026-09-10', 200001, 'CITY', 77, 58, 10, 515);
```
```text
error: column 'buyer' = '77' has no matching row in brokers(id)
```
**Fix:** insert the missing row first (`INSERT INTO brokers VALUES (77, '...')`), or correct the value. For a big import, load the parent table (brokers) before the child table (trade_log).
## Cannot delete a referenced row
```sql
DELETE FROM brokers WHERE id = 45;
```
```text
error: cannot remove brokers(id) = '45': table 'trade_log' column 'buyer' still refers to it (foreign key 'trade_log_buyer_fkey')
```
**Why:** trades still point to this broker. A plain `REFERENCES` never deletes them for you. (A foreign key written with `ON DELETE CASCADE` or `ON DELETE SET NULL` would; chapter 22.)
**Fix:** delete or change the trades first, or keep the broker. Changing the broker's `id` with UPDATE is refused for the same reason. Dropping the whole table gives: `cannot drop table 'brokers': column 'buyer' of table 'trade_log' REFERENCES it; drop that column or table first`.
## Missing value
```sql
INSERT INTO brokers (id) VALUES (99);
```
```text
error: column 'name' (STRING): cannot be NULL
```
```sql
INSERT INTO ochl_day (symbol, close) VALUES ('CITY', 505);
```
```text
error: the time column 'time' needs a value
```
**Fix:** give a value for `NOT NULL` and PRIMARY KEY columns and for the time column of a time-series table.
## Number out of range
```sql
INSERT INTO brokers VALUES (3000000000, 'Too Big');
```
```text
error: column 'id' (INT): 3000000000 is out of range (-2147483647 to 2147483647)
```
**Fix:** use a bigger type (LONG). For an existing table: `ALTER TABLE t ALTER COLUMN c TYPE LONG` (chapter 3). A column that is the time column or part of SORT BY or DEDUP KEY cannot change its type.
## Type mismatch
```sql
SELECT * FROM brokers WHERE name = 58;
```
```text
error: line 1, column 34: cannot compare STRING 'Keystone Securities' with LONG '58'
```
```sql
SELECT sum(symbol) FROM ochl_day;
```
```text
error: line 1, column 8: sum needs numbers, got 'CITY'
```
**Fix:** compare text with `'text'` and numbers with numbers. Use `CAST` when needed.
## Other query errors
| Message | Fix |
|---|---|
| `SAMPLE BY needs a table with a time column (a TIMESERIES table)` | SAMPLE BY only works on time-series tables. Use GROUP BY. |
| `column 'close' is used by a window function in a query with aggregates, so it must be a key of the groups: add it to the SELECT list or to GROUP BY` | With `GROUP BY` or `SAMPLE BY`, a window function reads the groups, not the rows. Give it an aggregate (`sma(last(close), 2)`) or a group key, or aggregate in a subquery and put the indicator outside (chapter 5). |
| `'sma' works over a series of rows and can only be used in a SELECT list` | Not in WHERE and not inside another series function. Use a subquery. |
| `column 'amount' is computed and cannot be set` | Computed columns (`AS (...)`) are not stored. Change the columns it is computed from. |
| `in subquery: ... unknown column 's.symbol'` | The name is wrong, or `s` is not a table of the outer query. Correlated subqueries work (chapter 23). |
| `subquery returned 3 rows; a value needs exactly one (add LIMIT 1?)` | A `(SELECT ...)` used as a value must return one row. |
| `time zone '...' is not supported` | Use `'Asia/Kathmandu'` or `'+05:45'`. |
| `offset '00:15' must look like '+05:45' or '-03:00'` | Write `WITH OFFSET '+00:15'`. |
| `DEDUP KEY must include the time column` | Add the time column to the DEDUP KEY. |
| `there is no unique key on (a, b) of table 't' for ON CONFLICT` | Add `PRIMARY KEY`, `UNIQUE (a, b)` or a `UNIQUE INDEX` on exactly these columns (chapter 23). |
| `ON CONFLICT DO UPDATE cannot change the same row twice in one statement` | Two new rows have the same key. Remove the copy, or use `DO NOTHING`. |
| `the query makes more than N groups (max_groups)` | The `GROUP BY` makes more groups than `--max-groups` allows. Add a time filter or group by fewer columns. (A `GROUP BY` that only needs much memory does not fail: it uses temporary files and is slower.) |
| `table 'x' is busy: ...`, `database is busy: ...`, `deadlock: ...` | Another transaction holds the table. Nothing of your statement was done. Wait a little and run the whole transaction again (chapter 15). |
The outputs of these are in `examples/05-queries/`, `examples/10-migrating-from-questdb/`, `examples/11-sql-reference/` and `examples/03-tables/`.
## Port in use
```text
error: Already exists: port 9945 on 127.0.0.1 is already in use
exit code: 1
```
**Why:** another program (often another `kdb serve`) listens on that port.
**Fix:** use another port (`--port 9901`), or find the program:
```powershell
Get-NetTCPConnection -LocalPort 9900 -State Listen | Select-Object OwningProcess
Get-Process -Id
```
## Token 401
```text
{"error":"access token required"} (HTTP 401)
{"error":"wrong access token"} (HTTP 401)
```
**Why:** the server was started with `--token`.
**Fix:** send `Authorization: Bearer `. In the console, enter the token in the dialog. If the console keeps asking, the saved token is wrong: clear the site data of `127.0.0.1:9900` in the browser, or open a private window. Check with `curl.exe -s http://127.0.0.1:9900/health`: `"auth_required":true` means a token is needed.
## 403 cross-site, cross-origin, host name
```text
{"error":"cross-site requests are not allowed"} (HTTP 403)
{"error":"cross-origin requests are not allowed"} (HTTP 403)
{"error":"host name 'market.example.com' is not allowed without an access token; use localhost or start the server with --token"} (HTTP 403)
```
**Why:** protection against web pages that try to use your local KDB (chapter 8).
**Fix:**
- Call KDB from a program or your backend, not from JavaScript on another web site.
- Open the console with `http://127.0.0.1:9900` or `http://localhost:9900`.
- Behind a reverse proxy or with a host name: start the server with `--token`.
## 405 GET changes data
```text
{"error":"this SQL changes data, so it must be sent with POST /exec (GET /exec only reads)"} (HTTP 405)
{"error":"CSV export needs a SELECT, SHOW or EXPLAIN statement"} (HTTP 400)
```
**Why:** `GET /exec` only runs reading statements, and `/exp` only exports reading statements. A web page could otherwise change your data with a simple link.
**Fix:** send INSERT, UPDATE, DELETE, CREATE, DROP, ALTER and BACKUP DATABASE with **POST**: `curl.exe http://127.0.0.1:9900/exec --data-binary "INSERT ..."`.
## Text in other languages looks broken
```powershell
kdb sql market "CREATE TABLE IF NOT EXISTS names_np (symbol SYMBOL PRIMARY KEY, name_np STRING)"
# Text in any language (here Nepali) works directly on the command line:
kdb sql market "INSERT OR REPLACE INTO names_np VALUES ('VOLT', 'भोल्ट रिभर')"
kdb sql market "SELECT symbol, name_np FROM names_np"
# It also works from a UTF-8 file (or over HTTP):
$OutputEncoding = New-Object System.Text.UTF8Encoding $false
"INSERT OR REPLACE INTO names_np VALUES ('VOLT', 'भोल्ट रिभर');" | Set-Content np.sql -Encoding UTF8
Get-Content np.sql -Raw -Encoding UTF8 | kdb sql market -
kdb sql market "SELECT symbol, name_np FROM names_np"
```
```text
table 'names_np' created
1 row(s) inserted
symbol | name_np
-------+-----------
VOLT | भोल्ट रिभर
(1 row, 0.2 ms)
0 row(s) inserted, 1 replaced
symbol | name_np
-------+-----------
VOLT | भोल्ट रिभर
(1 row, 0.2 ms)
```
**If you see `????`:** Windows PowerShell 5.1 sends pipe text as ASCII unless `$OutputEncoding` is UTF-8. Set it (as above) before you pipe SQL into `kdb sql -` or `kdb shell`. Text in command-line arguments and over HTTP is fine.
If the letters look broken only in the **console window**, the window font may not have Devanagari. Use Windows Terminal, or save the output as CSV and open it in an editor.
## Damage
Messages like `failed its checksum`, `DAMAGED`, `the end of the log is damaged`, exit code 2:
1. Stop the server and all programs that use the database.
2. Copy the whole database folder to a safe place.
3. `kdb check ` to see the damage.
4. `kdb repair --backups `.
5. If it still fails: `kdb restore --logs-from `.
Chapter 9 shows a full real example.
## The server stopped
If the server process ends without Ctrl+C (crash, closed window, killed), start it again. The banner then shows `last shutdown: not clean (crash or kill)` and KDB replays the log. Committed data is not lost.
## FAQ
**Where are my times? They look 5:45 hours early.**
KDB shows UTC. `05:15Z` is 11:00 in Nepal. The chart in the console shows your local time.
**Can two programs write at the same time?**
Not to the same folder directly. Run one `kdb serve` and let all programs use its HTTP API or its PostgreSQL port. In the server, writers of **different** tables run at the same time. Writers of the **same** table take turns. A read waits for a long write of the same database unless the server runs with `--reader-yield on` (the Docker image does), then it runs between the writer's statements (chapter 15).
**Is a statement saved when KDB says "inserted"?**
Yes, it is on the disk. A power cut after that does not lose it.
**How do I undo a change?**
Inside `BEGIN` ... `COMMIT`, `ROLLBACK` undoes everything since `BEGIN` (chapter 13). A dropped table comes back with `UNDROP TABLE` for 7 days (chapter 21). After a committed change, restore a backup to the time before it (`kdb restore --until`, chapter 9). Make a backup before big changes.
**How big can a table be?**
There is no fixed limit. Very old data can be removed quickly with `ALTER TABLE ... DROP PARTITION OLDER THAN 1 YEAR`, or by itself with a TTL (`ALTER TABLE ... SET TTL 90 DAYS`, chapter 3).
**Why is my query slow?**
Add a time filter on the time-series table (`time IN '2026-09'`), select fewer columns, and check with `EXPLAIN` how many partitions are read (chapter 5).
**Can I use DBeaver, psql or Excel to connect?**
DBeaver and `psql`: yes, through the PostgreSQL port 8832 (chapter 14). For Excel, export CSV (`kdb sql ... --format csv`, or the console's **Export CSV**).
**Does KDB know stock exchange holidays?**
No. `is_trading_day()` only checks for a Sunday-to-Thursday week (as on some exchanges). Keep a holiday table if you need it.
**Can I copy the database folder to another PC?**
Yes, when no program has it open. Better: `kdb backup`, copy the backup folder, `kdb restore` on the other PC. The restore checks every file.
**Do Linux and Windows use the same files?**
The file format is the same on all systems (chapter 1, FORMAT.md).
**Where is `kdb.conf`?**
Next to the `kdb` program (or the file named with `--config `). It is a small JSON file that the console's **Settings** page writes. It holds `data_dir`, `wal_archive_mb`, `trash_keep_hours`, `keep_versions`, and the folders of the console, the help and the Claude skill (`web_dir`, `help_dir`, `skill_dir`). An option on the command line always wins over it. Everything else is a command-line option (`kdb` with no command lists them).
## Try it yourself
1. You get `error: line 3, column 5: unknown column 'volum'`. What do you do?
2. A script prints `the database is open in another program`, but no server is running. What else can hold the database?
3. An INSERT of 5000 rows fails with a duplicate key. How many rows were saved?
Answers
1. Look at line 3, column 5 of your SQL, and fix the name to `volume` (the message lists the real columns).
2. A `kdb shell` or `kdb sql` in another window, or a scheduled task (backup script) that is running. Check with `Get-Process kdb`.
3. None. One statement is all or nothing. Use `INSERT OR REPLACE` or `INSERT OR IGNORE`, or remove the duplicates.
---
Next: [13. Functions, procedures and triggers](13-functions-procedures-triggers.md). Back to the index. Also read the [known limitations](known-limitations.md).
---
# 13. Functions, procedures and triggers
- Category: Tutorial
## What you will learn
- Save a formula once and use it in many queries (**functions**).
- Save several steps under one name and run them with `CALL` (**procedures**).
- Save several changes together, or none of them (**transactions**).
- Run SQL by itself when a table changes (**triggers**).
- Read a big result a few rows at a time (**cursors**).
- See every step a procedure takes (`WITH DEBUG`).
The examples use the database `market` with the fresh sample data. Each example is a file in `examples/13-functions-procedures-triggers/`. The results are real.
Functions, procedures and triggers are saved in the database like tables and views: they survive a restart, a crash and a backup.
## Saved functions
A saved function is a formula with a name. Its body is **one expression**.
```sql
CREATE [OR REPLACE] FUNCTION name(param TYPE, ...) [RETURNS TYPE] AS (expression);
DROP FUNCTION [IF EXISTS] name;
SHOW FUNCTIONS;
```
```sql
-- A saved function: the change from one price to another, in percent.
CREATE FUNCTION change_pct(price DOUBLE, prev DOUBLE) RETURNS DOUBLE
AS ((price - prev) / prev * 100);
SELECT symbol, open, close, round(change_pct(close, open), 2) AS change
FROM ochl_day
WHERE time IN '2026-09-10'
ORDER BY change DESC;
-- Functions can call other functions, and work in WHERE and GROUP BY.
CREATE FUNCTION move(price DOUBLE, prev DOUBLE) RETURNS STRING
AS (CASE WHEN change_pct(price, prev) > 0.5 THEN 'up' WHEN change_pct(price, prev) < -0.5 THEN 'down' ELSE 'flat' END);
SELECT move(close, open) AS move, count(*) AS days
FROM ochl_day
WHERE symbol = 'CITY'
GROUP BY move(close, open)
ORDER BY move;
```
```text
function 'change_pct' created
symbol | open | close | change
-------+-------+-------+-------
VOLT | 185.6 | 191.2 | 3.02
CITY | 504.6 | 504.9 | 0.06
COAST | 380.1 | 376.1 | -1.05
(3 rows, 0.2 ms)
function 'move' created
move | days
-----+-----
down | 4
flat | 13
up | 2
(3 rows, 0.2 ms)
```
**Why is it not called `pct_change`?** Because `pct_change` is already a built-in function (chapter 5). A saved function cannot have the name of a built-in one:
```sql
CREATE FUNCTION pct_change(price DOUBLE, prev DOUBLE) AS ((price - prev) / prev * 100);
```
```text
error: 'pct_change' is a built-in function; choose another name for the function
```
A function can only use its **parameters**, not the columns of a table. Give it the columns as arguments:
```sql
CREATE FUNCTION day_range(high DOUBLE) AS (high - low);
```
```text
error: the body of function 'day_range' uses low, which is not one of its parameters (high DOUBLE); a function can only use its parameters
```
| Rule | Detail |
|---|---|
| Speed | A call is replaced by its body when the query starts. `change_pct(close, open)` is exactly as fast as `(close - open) / open * 100`. |
| Types | Each argument is converted to the parameter's type (like `CAST`). `RETURNS` converts the result. |
| Body | One expression: operators, `CASE`, built-in functions like `round` or `concat`, and other saved functions. No subqueries, aggregates (`sum`) or window functions (`sma`) inside. Use them around the call: `sum(change_pct(close, open))`. |
| Nesting | A function may call other functions, at most 16 levels deep. A function cannot call itself, also not through another function. |
| Where | `SELECT`, `WHERE`, `GROUP BY`, `ORDER BY`, views, procedures. **Not** in computed columns (`AS (...)` in `CREATE TABLE`). |
| Names | Not the name of a built-in function. Names do not care about case. |
| Change | `CREATE OR REPLACE FUNCTION` changes it. Views and procedures that use it get the new body. |
## Stored procedures
A procedure is a list of statements with a name. You run it with `CALL`.
```sql
CREATE [OR REPLACE] PROCEDURE name(param TYPE, ...) AS BEGIN
statement;
...
END;
CALL name(arguments) [WITH DEBUG];
DROP PROCEDURE [IF EXISTS] name;
SHOW PROCEDURES;
```
This procedure builds the daily candles of one day from the minute candles (the job the app runs after the market closes):
```sql
-- End of day: build the daily candles of one day from the minute candles.
CREATE PROCEDURE end_of_day(day DATE) AS BEGIN
DECLARE minutes LONG DEFAULT 0;
SET minutes = (SELECT count(*) FROM ochl_minutes
WHERE time >= day AND time < dateadd('d', 1, day));
IF minutes = 0 THEN
RAISE concat('no minute candles for ', day);
END IF;
FOR c IN (SELECT symbol, first(open) AS open, max(high) AS high, min(low) AS low,
last(close) AS close, sum(volume) AS volume
FROM ochl_minutes
WHERE time >= day AND time < dateadd('d', 1, day)
GROUP BY symbol) DO
INSERT OR REPLACE INTO ochl_day VALUES (day, c.symbol, c.open, c.high, c.low, c.close, c.volume);
END FOR;
PRINT concat(minutes, ' minute candles read');
SELECT symbol, open, high, low, close, volume FROM ochl_day WHERE time = day ORDER BY symbol;
END;
```
```text
procedure 'end_of_day' created
```
Now delete two daily candles and build them again:
```sql
-- Remove two daily candles, then build them again from the minute candles.
DELETE FROM ochl_day WHERE time IN '2026-09-10' AND symbol IN ('CITY', 'COAST');
CALL end_of_day('2026-09-10');
```
```text
2 row(s) deleted
procedure 'end_of_day' done: 2 row(s) changed
symbol | open | high | low | close | volume
-------+-------+-------+-------+-------+-------
CITY | 504.6 | 507 | 500.7 | 504.9 | 183040
COAST | 380.1 | 382 | 373.2 | 376.1 | 186200
VOLT | 185.6 | 192.1 | 184.2 | 191.2 | 41470
(3 rows, 0.1 ms)
output
-----------------------
476 minute candles read
(1 row, 0.0 ms)
```
A `CALL` returns more than one result: its own message, the rows of every `SELECT` in the procedure, and a result called `print` with the `PRINT` lines. The web console and `POST /exec` show them like several statements.
### What you can write in a procedure
| Statement | Meaning |
|---|---|
| `SELECT`, `INSERT`, `UPDATE`, `DELETE` | As usual. A `SELECT` returns its rows as an extra result of the `CALL`. |
| `DECLARE name TYPE [DEFAULT expr];` | A variable. Without `DEFAULT` it is NULL. |
| `SET name = expr;` | Give a variable a new value. `SET n = (SELECT count(*) FROM t);` takes a value from a query. |
| `IF cond THEN ... [ELSIF cond THEN ...] [ELSE ...] END IF;` | Choose. NULL counts as false. |
| `WHILE cond DO ... END WHILE;` | Repeat while the condition is true. |
| `FOR r IN (SELECT ...) DO ... END FOR;` | Once for every row of the query. `r.column` is a value of the current row. |
| `DECLARE c CURSOR FOR SELECT ...;` `OPEN c;` `FETCH c INTO a, b;` `CLOSE c;` | Read a query one row at a time (see [Cursors](#cursors)). |
| `CALL other(args);` | Run another procedure (at most 16 levels deep). |
| `RETURN;` | Stop the procedure. Its changes are kept. |
| `RAISE expr;` | Stop with an error. **Everything the `CALL` changed is undone.** |
| `PRINT expr;` | Add a line to the `print` result (a debug message). |
Every statement ends with `;`. `CREATE`, `DROP`, `ALTER`, `TRUNCATE`, `BACKUP`, `BEGIN` and `COMMIT` cannot be used inside a procedure.
### Variables and parameters
- Use them by name in any expression: `WHERE time >= day`, `VALUES (day, c.symbol)`.
- A **column name wins** over a variable with the same name. Give variables names that are not column names (`minutes`, not `volume`).
- Every procedure has a variable `FOUND` (true when the last `FETCH` read a row).
- A value is converted to the variable's type. `CALL end_of_day('2026-09-10')` turns the text into a `DATE`.
### RAISE: stop and undo
There are no minute candles for 2026-09-12:
```sql
CALL end_of_day('2026-09-12');
```
```text
error: procedure 'end_of_day', line 5: no minute candles for 2026-09-12
```
`line 5` is the line in the procedure as `SHOW PROCEDURES` prints it (`CREATE PROCEDURE ... AS BEGIN` is line 1, and each statement starts a new line).
**A `CALL` is one transaction.** Its changes are saved together at the end, with one write to the log. If any statement fails, or `RAISE` runs, nothing of the `CALL` is saved. Statements inside the procedure see the changes made earlier in the same `CALL`.
## Transactions: BEGIN, COMMIT, ROLLBACK
Normally every statement is saved on its own. Between `BEGIN` and `COMMIT`, statements are saved **together, or not at all**.
```sql
-- ROLLBACK: nothing is saved.
BEGIN;
DELETE FROM securities WHERE symbol = 'ACME';
SELECT count(*) AS securities FROM securities;
ROLLBACK;
SELECT count(*) AS securities FROM securities;
-- COMMIT: both rows are saved together, in one log write.
BEGIN;
INSERT INTO brokers VALUES (17, 'ABC Securities');
INSERT INTO brokers VALUES (18, 'Sagarmatha Securities');
COMMIT;
SELECT id, name FROM brokers WHERE id IN (17, 18);
```
```text
transaction started
1 row(s) deleted
securities
----------
4
(1 row, 0.1 ms)
transaction rolled back: 1 change(s) undone
securities
----------
5
(1 row, 0.0 ms)
transaction started
1 row(s) inserted
1 row(s) inserted
transaction committed: 2 change(s) saved
id | name
---+----------------------
17 | ABC Securities
18 | Sagarmatha Securities
(2 rows, 0.0 ms)
```
When a statement fails, the whole transaction is undone:
```sql
-- The second INSERT fails (id 1 is already used), so the first one is undone too.
BEGIN;
INSERT INTO brokers VALUES (19, 'Trishakti Securities');
INSERT INTO brokers VALUES (1, 'Keystone Securities again');
COMMIT;
```
```text
transaction started
1 row(s) inserted
error: a row with id='1' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it (the transaction was rolled back)
```
| Rule | Detail |
|---|---|
| Where | In one text sent to `kdb sql`, `POST /exec` or the web console. In `kdb shell`, in an HTTP session (`POST /session`) and over the PostgreSQL protocol, a transaction can go over many requests (chapter 15). |
| No COMMIT | A text that ends without `COMMIT` is rolled back, with the error `BEGIN without COMMIT` (not in a session: there it stays open). |
| Crash | Changes are written to the log only at `COMMIT`. After a crash or power cut, a committed transaction is there completely, and a transaction without `COMMIT` is not there at all. |
| Not inside | `CREATE`, `DROP`, `ALTER`, `RENAME`, `TRUNCATE`, `BACKUP DATABASE`. Run them before `BEGIN` or after `COMMIT`. |
| Size | At most 10 million changed rows per transaction (`CALL` too). Until `COMMIT`, KDB keeps the old version of every changed row in memory. |
| Others | While a session has an open transaction, other sessions **wait** before they change a table it used (at most 5 seconds, then `table 'x' is busy`); changes of other tables go on. Their reads do not wait for an open transaction: they see the rows without its changes. Chapter 15 explains this. |
## Triggers
A trigger runs SQL by itself every time a table is changed.
```sql
CREATE [OR REPLACE] TRIGGER name {BEFORE | AFTER} {INSERT | UPDATE | DELETE} ON table
EXECUTE {CALL procedure(args) | one statement | BEGIN statements END};
CREATE [OR REPLACE] TRIGGER name AFTER INSERT ON table DEFERRED [TIMEOUT 10min]
EXECUTE ...; -- runs after the commit, in the background
DROP TRIGGER [IF EXISTS] name;
ALTER TABLE table {ENABLE | DISABLE} TRIGGER name;
SHOW TRIGGERS;
```
The app shows the last price of every symbol. Instead of searching `live_data` every time, keep a small `latest_price` table up to date:
```sql
-- Keep the last price of every symbol in a small table, always up to date.
CREATE TABLE latest_price (symbol SYMBOL PRIMARY KEY, ltp DOUBLE, time TIMESTAMP);
CREATE TRIGGER keep_latest AFTER INSERT ON live_data EXECUTE BEGIN
FOR r IN (SELECT symbol, last(ltp) AS ltp, max(timestamp) AS time FROM NEW GROUP BY symbol) DO
INSERT OR REPLACE INTO latest_price VALUES (r.symbol, r.ltp, r.time);
END FOR;
END;
INSERT INTO live_data VALUES
('2026-09-11T11:00:00+05:45', 'CITY', 528.0, 10, 0.9, 0.17, 527.1, 528.0, 527.1, 527.5, 527.1, 1200),
('2026-09-11T11:01:00+05:45', 'CITY', 529.5, 20, 2.4, 0.46, 527.1, 529.5, 527.1, 528.2, 527.1, 3400),
('2026-09-11T11:00:00+05:45', 'COAST', 402.5, 10, 0.6, 0.15, 401.9, 402.5, 401.9, 402.2, 401.9, 800);
SELECT symbol, ltp, time FROM latest_price ORDER BY symbol;
```
```text
table 'latest_price' created
trigger 'keep_latest' created on table 'live_data'
3 row(s) inserted
symbol | ltp | time
-------+-------+-------------------------
CITY | 529.5 | 2026-09-11T05:16:00.000Z
COAST | 402.5 | 2026-09-11T05:15:00.000Z
(2 rows, 0.1 ms)
```
Inside a trigger, two tables exist:
| Table | INSERT | UPDATE | DELETE |
|---|---|---|---|
| `NEW` | the inserted rows | the rows after the change | (empty) |
| `OLD` | (empty) | the rows before the change | the deleted rows |
They have the columns of the changed table. Use them like tables: `SELECT ... FROM NEW`, `FROM OLD o JOIN NEW n ON o.id = n.id`.
### A BEFORE trigger can refuse a change
```sql
-- A BEFORE trigger can refuse a change.
CREATE TRIGGER check_price BEFORE INSERT ON live_data EXECUTE BEGIN
IF EXISTS (SELECT 1 FROM NEW WHERE ltp <= 0) THEN
RAISE 'ltp must be more than 0';
END IF;
END;
INSERT INTO live_data VALUES
('2026-09-11T11:02:00+05:45', 'CITY', 0, 10, 0, 0, 527.1, 529.5, 527.1, 528.2, 527.1, 3500);
```
```text
trigger 'check_price' created on table 'live_data'
error: trigger 'check_price', line 3: ltp must be more than 0
```
Nothing was written, and `latest_price` did not change:
```sql
-- Nothing was written by the refused INSERT.
SELECT count(*) AS rows FROM live_data WHERE timestamp > '2026-09-11T11:01:00+05:45';
SELECT symbol, ltp FROM latest_price ORDER BY symbol;
SHOW TRIGGERS;
```
```text
rows
----
0
(1 row, 0.3 ms)
symbol | ltp
-------+------
CITY | 529.5
COAST | 402.5
(2 rows, 0.1 ms)
name | table | timing | event | enabled | definition
------------+-----------+--------+--------+---------+-------------------------------------------------
check_price | live_data | BEFORE | INSERT | true | CREATE TRIGGER check_price BEFORE INSERT ON l...
keep_latest | live_data | AFTER | INSERT | true | CREATE TRIGGER keep_latest AFTER INSERT ON li...
(2 rows, 0.0 ms)
```
| Rule | Detail |
|---|---|
| How often | **Once per statement**, not once per row: once for an `INSERT` of 1,000 rows, once for a `Database::InsertRows` call, once for a `POST /write` (line protocol) request. A statement that changes no row does not run triggers. |
| Together | The trigger runs in the same transaction as the statement. If the trigger fails, the statement is undone too. |
| BEFORE / AFTER | `BEFORE` runs before the rows are written (use it to check them, `RAISE` refuses). `AFTER` runs after (the table already has the new rows). |
| Nesting | A trigger may change a table that has triggers. At most 16 levels: a trigger that fires itself stops with `triggers are nested too deeply`. |
| Order | Several triggers of the same kind on one table run in name order. |
| DROP TABLE | A table with triggers cannot be dropped or renamed: `table 'live_data' has triggers (...); DROP TRIGGER them first`. |
| Off for a while | `ALTER TABLE live_data DISABLE TRIGGER keep_latest;` and later `ENABLE TRIGGER`. |
| Speed | A trigger slows only its own table. Writes into that table (`InsertRows` calls and line-protocol requests too) are written one at a time, not grouped with other requests, and the loader waits for the trigger body. Writes into other tables are as fast as before. Keep trigger bodies short, or make a slow one `DEFERRED` (below). |
### Deferred triggers: slow work after the commit
A normal trigger runs **inside** the insert, so the loader waits for it. A trigger that builds summary tables from a whole day of trades can take seconds. Make it `DEFERRED`: the insert commits and returns at once, and the trigger's work runs after it, in the background of the server.
```sql
CREATE TABLE day_totals (symbol SYMBOL PRIMARY KEY, quantity LONG);
CREATE TRIGGER build_totals AFTER INSERT ON trade_log DEFERRED TIMEOUT 10min EXECUTE
INSERT OR REPLACE INTO day_totals
SELECT symbol, sum(quantity) FROM trade_log GROUP BY symbol;
```
```text
table 'day_totals' created
trigger 'build_totals' created on table 'trade_log' (deferred: it runs after each INSERT commits, one job at a time)
```
| Rule | Detail |
|---|---|
| Which | Only `AFTER INSERT` triggers can be deferred. |
| Jobs | Every insert (`INSERT`, `POST /write`, `POST /imp`, a `COMMIT` that inserted rows) makes one **job**. The jobs of a trigger run one at a time, oldest first. They are kept on disk, so a restart does not lose them. |
| Where they run | In a running server (`kdb serve`). `kdb sql` exits right after its statement, so a job it made runs the next time a server opens the database. |
| Failure | A failed job stops its trigger's queue. Nothing is skipped by itself. `RETRY TRIGGER QUEUE build_totals` runs it again; `SKIP TRIGGER JOB build_totals ` leaves it out. |
| Watch | `GET /api/trigger-queue` shows the queue and the lag. `POST /write?...&wait=trigger` answers only after its own jobs ended. |
| SHOW TRIGGERS | The timing column says `AFTER (deferred)`. |
The help topic **Triggers** has every detail.
## Cursors
A cursor reads the rows of a query one by one, or a few at a time.
### In a procedure
```sql
-- A cursor loop: read rows one at a time, stop when FOUND is false.
CREATE PROCEDURE biggest_trade(day DATE) AS BEGIN
DECLARE sym SYMBOL;
DECLARE qty LONG;
DECLARE best_sym SYMBOL;
DECLARE best_qty LONG DEFAULT 0;
DECLARE trades CURSOR FOR
SELECT symbol, quantity FROM trade_log WHERE date = day ORDER BY transaction_id;
OPEN trades;
FETCH trades INTO sym, qty;
WHILE found DO
IF qty > best_qty THEN
SET best_qty = qty;
SET best_sym = sym;
END IF;
FETCH trades INTO sym, qty;
END WHILE;
CLOSE trades;
PRINT concat('biggest trade on ', day, ': ', best_qty, ' shares of ', best_sym);
END;
CALL biggest_trade('2026-09-10');
```
```text
procedure 'biggest_trade' created
procedure 'biggest_trade' done: 0 row(s) changed
output
------------------------------------------------
biggest trade on 2026-09-10: 1000 shares of C...
(1 row, 0.0 ms)
```
`OPEN` runs the query. `FETCH ... INTO` puts the next row into the variables (one variable per column) and sets `FOUND`. A `FOR` loop is shorter for the same job; a cursor is useful when you read two queries side by side, or stop early.
### In SQL: read a big result bit by bit
```sql
-- Read a big result a few rows at a time.
DECLARE m CURSOR FOR
SELECT time, symbol, close FROM ochl_minutes WHERE time IN '2026-09-10' ORDER BY time, symbol;
FETCH NEXT 3 FROM m;
FETCH NEXT 3 FROM m;
CLOSE m;
```
```text
cursor 'm' declared: 476 row(s)
time | symbol | close
-------------------------+--------+------
2026-09-10T05:15:00.000Z | CITY | 504.4
2026-09-10T05:15:00.000Z | COAST | 380
2026-09-10T05:16:00.000Z | CITY | 504.6
(3 rows, 0.0 ms)
3 row(s) fetched, 473 left
time | symbol | close
-------------------------+--------+------
2026-09-10T05:16:00.000Z | COAST | 379.6
2026-09-10T05:17:00.000Z | CITY | 504.2
2026-09-10T05:17:00.000Z | COAST | 380.1
(3 rows, 0.0 ms)
3 row(s) fetched, no more rows
```
`FETCH NEXT n FROM c`, `FETCH ALL FROM c` (the rest), `CLOSE c`. `DECLARE` runs the query once and keeps its rows (a snapshot): later changes to the table do not change what `FETCH` returns.
A cursor lives until `CLOSE`, until the end of the text (`kdb sql`, `POST /exec`), or until the `kdb shell` session ends.
### In kdb shell: over many lines
In `kdb shell`, a transaction and a cursor stay open from one line to the next:
```powershell
@"
BEGIN;
UPDATE securities SET sector = 'Banks' WHERE symbol = 'CITY';
SELECT symbol, sector FROM securities WHERE symbol = 'CITY';
ROLLBACK;
SELECT symbol, sector FROM securities WHERE symbol = 'CITY';
DECLARE s CURSOR FOR SELECT symbol FROM securities ORDER BY symbol;
FETCH NEXT 2 FROM s;
FETCH ALL FROM s;
CLOSE s;
CREATE PROCEDURE count_sector(wanted STRING) AS BEGIN
DECLARE n LONG;
SET n = (SELECT count(*) FROM securities WHERE sector = wanted);
PRINT concat(wanted, ': ', n);
END;
CALL count_sector('Banking');
.exit
"@ | kdb shell market
```
```text
KDB 0.2.0-dev shell on market
End statements with ';'. Type .help for commands, .exit to quit.
kdb> transaction started
kdb> 1 row(s) updated
kdb> symbol | sector
-------+-------
CITY | Banks
(1 row, 0.1 ms)
kdb> transaction rolled back: 1 change(s) undone
kdb> symbol | sector
-------+--------
CITY | Banking
(1 row, 0.0 ms)
kdb> cursor 's' declared: 5 row(s)
kdb> symbol
------
ACME
CITY
(2 rows, 0.0 ms)
2 row(s) fetched, 3 left
kdb> symbol
------
COAST
TELCO
VOLT
(3 rows, 0.0 ms)
3 row(s) fetched, no more rows
kdb> cursor 's' closed
kdb> ...> ...> ...> ...> procedure 'count_sector' created
kdb> procedure 'count_sector' done: 0 row(s) changed
output
----------
Banking: 2
(1 row, 0.0 ms)
kdb>
```
The shell knows that a `CREATE PROCEDURE ... BEGIN ... END;` is one statement, even though it has `;` inside (the `...>` prompts). If you leave the shell with a transaction still open, it is rolled back. If a statement fails inside the transaction, the transaction is rolled back and the shell accepts only `ROLLBACK` until you send it (so the next lines cannot run outside the transaction by mistake).
**A trap:** the parameter is called `wanted`, not `name`. The table `securities` has a column `name`, and a column name wins over a variable: with a parameter called `name`, `WHERE sector = name` compares `sector` with the `name` column and counts 0.
### Over HTTP
`POST /cursor` with a `SELECT` as the body keeps the rows on the server. `GET /cursor/?fetch=N` returns the next rows, `DELETE /cursor/` closes it. Chapter 8 has the example ([Cursors](08-http-api.md#cursors-read-a-big-result-bit-by-bit)).
| Cursor rule | Detail |
|---|---|
| Open at the same time | At most 16 cursors per database (SQL and HTTP together). |
| Unused | A cursor closes after 10 minutes without `FETCH`. |
| Size | At most 10 million rows. The rows are kept in memory until the cursor closes. |
## Debugging: WITH DEBUG
`CALL ... WITH DEBUG` adds a result called `debug`, with one row for every step:
```sql
CALL end_of_day('2026-09-10') WITH DEBUG;
```
```text
procedure 'end_of_day' done: 2 row(s) changed
symbol | open | high | low | close | volume
-------+-------+-------+-------+-------+-------
CITY | 504.6 | 507 | 500.7 | 504.9 | 183040
COAST | 380.1 | 382 | 373.2 | 376.1 | 186200
VOLT | 185.6 | 192.1 | 184.2 | 191.2 | 41470
(3 rows, 0.0 ms)
step | routine | statement | rows | time_us | changes
-----+------------+--------------------------------------------------+------+---------+-------------------------------------------------
1 | | CALL end_of_day('2026-09-10') WITH DEBUG | 2 | 1268 | partitions read: 3
2 | end_of_day | DECLARE minutes LONG DEFAULT 0 | 0 | 2 | minutes=0
3 | end_of_day | SET minutes = (SELECT count(*) FROM ochl_minu... | 0 | 574 | minutes=476, partitions read: 1
4 | end_of_day | IF minutes = 0 THEN | 0 | 2 | false
5 | end_of_day | FOR c IN (SELECT symbol, first(open) AS open,... | 2 | 340 | partitions read: 1
6 | end_of_day | FOR c: row 1 of 2 | 0 | 0 | c.symbol=CITY, c.open=504.6, c.high=507, c.lo...
7 | end_of_day | INSERT OR REPLACE INTO ochl_day VALUES (day, ... | 1 | 131 |
8 | end_of_day | FOR c: row 2 of 2 | 0 | 0 | c.symbol=COAST, c.open=380.1, c.high=382, c.l...
9 | end_of_day | INSERT OR REPLACE INTO ochl_day VALUES (day, ... | 1 | 14 |
10 | end_of_day | PRINT concat(minutes, ' minute candles read') | 0 | 5 | 476 minute candles read
11 | end_of_day | SELECT symbol, open, high, low, close, volume... | 3 | 46 | partitions read: 1
(11 rows, 0.0 ms)
```
| Column | Meaning |
|---|---|
| `step` | 1, 2, 3, ... in the order the steps started |
| `routine` | the procedure (or `trigger name`) the step is in |
| `statement` | the statement, or the control line (`IF ... THEN`, `WHILE ... DO`, `FOR r: row 2 of 5`) |
| `rows` | rows changed (INSERT, UPDATE, DELETE), rows returned (SELECT), rows read (OPEN, FOR) |
| `time_us` | time of the step in microseconds (the first row: the whole `CALL`) |
| `changes` | variables that got a new value (`minutes=476`), the result of a condition (`true`/`false`), the `PRINT` text, or `failed: `. A step that read table partitions adds how many: `partitions read: 1`. The first row counts them for the whole `CALL`. |
With `WITH DEBUG`, `PRINT` lines are in the `debug` result (there is no separate `print` result). When the `CALL` fails, the `debug` rows up to the error are still returned, and the last one says `failed`: `kdb sql` and `kdb shell` print them before the error, and `POST /exec` returns them in `results` next to the `error`. Triggers that run during the `CALL` add their steps too. At most 100,000 steps are recorded.
`time_us` changes every time you run it: the numbers above are from one run.
## Keeping the definitions
```sql
SHOW PROCEDURES;
ALTER TABLE live_data DISABLE TRIGGER check_price;
DROP TRIGGER check_price;
DROP FUNCTION move;
DROP FUNCTION IF EXISTS move;
SHOW FUNCTIONS;
```
```text
name | arguments | definition
--------------+---------------+-------------------------------------------------
biggest_trade | day DATE | CREATE PROCEDURE biggest_trade(day DATE) AS B...
count_sector | wanted STRING | CREATE PROCEDURE count_sector(wanted STRING) ...
end_of_day | day DATE | CREATE PROCEDURE end_of_day(day DATE) AS BEGI...
(3 rows, 0.0 ms)
trigger 'check_price' disabled
trigger 'check_price' dropped
function 'move' dropped
function 'move' does not exist
name | arguments | returns | definition
-----------+---------------------------+---------+-------------------------------------------------
change_pct | price DOUBLE, prev DOUBLE | DOUBLE | CREATE FUNCTION change_pct(price DOUBLE, prev...
(1 row, 0.0 ms)
```
The table format cuts long text after 48 characters. To see a whole definition, use `--format csv` or `--format json`: `kdb sql market "SHOW PROCEDURES" --format csv`.
KDB checks a procedure's statements for **syntax** when you create it, and for **tables and columns** when it runs. A procedure that uses a table you dropped later fails at `CALL` with a clear message. `DROP FUNCTION` and `DROP PROCEDURE` do not check who uses them (views, other procedures, triggers).
## Limits
| Limit | Value |
|---|---|
| Nesting of saved functions | 16 levels |
| Nesting of `CALL` | 16 levels |
| Nesting of triggers | 16 levels |
| Rows changed in one transaction (`BEGIN`/`COMMIT`, `CALL`, a statement with triggers) | 10 million (`DatabaseOptions::max_transaction_rows`) |
| Rounds of one `WHILE` loop | 10 million |
| Open cursors per database | 16 (`DatabaseOptions::max_cursors`) |
| Unused cursor closes after | 10 minutes (`DatabaseOptions::cursor_idle_ms`) |
| Rows in one cursor | 10 million |
| Steps in a `debug` result | 100,000 |
## Try it yourself
1. Write a function `day_range_pct(high DOUBLE, low DOUBLE, close DOUBLE)` that gives `(high - low) / close * 100`, and use it on `ochl_day` for 2026-09-10.
2. Why does `CALL end_of_day('2026-09-12')` leave `ochl_day` exactly as it was?
3. You want `latest_price` to also forget a symbol when all its `live_data` rows are deleted. Which trigger do you add, `BEFORE` or `AFTER`, and on which event?
Answers
1. `CREATE FUNCTION day_range_pct(high DOUBLE, low DOUBLE, close DOUBLE) RETURNS DOUBLE AS ((high - low) / close * 100);` then `SELECT symbol, round(day_range_pct(high, low, close), 2) FROM ochl_day WHERE time IN '2026-09-10';`
2. `RAISE` stops the procedure with an error, and a `CALL` is one transaction: nothing it did is saved. (Here it had not changed anything yet anyway.)
3. `AFTER DELETE ON live_data`. After the delete, the body can check `SELECT symbol FROM OLD` against what is left in `live_data`, and `DELETE FROM latest_price WHERE symbol = ...` for symbols with no rows left.
---
Back to the index. Also read the [known limitations](known-limitations.md).
---
# 14. PostgreSQL protocol
- Category: Tutorial
## What you will learn
- How `kdb serve` speaks the **PostgreSQL protocol** on port **8832**.
- How to connect from Go (`pgx`), Python, Node, `psql` and DBeaver.
- How KDB types become Postgres types. How times work.
- Transactions, error codes, and what does not work yet.
KDB is not PostgreSQL. But it speaks the same "wire protocol" (the messages between a program and the database). So a normal Postgres driver can send SQL to KDB and read the rows. You write **KDB SQL** (chapter 11). Much everyday PostgreSQL SQL works too: `SERIAL`, `ON CONFLICT`, `RETURNING`, `WITH`, arrays, `information_schema` (chapters 22 and 23).
## Start the server
`kdb serve` starts the Postgres port together with the HTTP port:
```powershell
kdb serve --token my-long-secret
```
```text
KDB 0.2.0-dev
console http://127.0.0.1:9900
postgres 127.0.0.1:8832 (PostgreSQL protocol, no TLS; password = the token)
...
```
Options:
| Option | Meaning | Default |
|---|---|---|
| `--pg-port N` | The Postgres port. `--pg-port 0` turns it off. | `8832` |
| `--bind ADDRESS` | The address for **both** ports (HTTP and Postgres). | `127.0.0.1` |
| `--token T` | The password for every Postgres user. | none |
| `--pg-timestamptz` | Send KDB `TIMESTAMP` as `timestamptz` (see [Times](#times)). | off |
## Security: read this first
- **No TLS.** KDB answers "no" when a client asks for TLS (SSL). The password is safe (SCRAM-SHA-256 never sends it), but **the data is not encrypted**.
- So keep port 8832 on `127.0.0.1` (the default). Your Go backend on the same VPS connects to `127.0.0.1`.
- From your PC, use an **SSH tunnel**:
```powershell
ssh -N -L 8832:127.0.0.1:8832 user@your-vps
```
Then connect to `127.0.0.1:8832` on your PC. The tunnel encrypts everything.
- **Without `--token`**, only programs on the same computer (`127.x.x.x`) can connect, and no password is asked. Other computers are refused.
- **With `--token`**, every user name works, and the password is the token.
- **With users** (chapter 16), log in with a KDB user name and its password (SCRAM-SHA-256). Each statement runs with that user's role; a refused one gets SQLSTATE `42501`. Connecting needs at least `read` on the database. With users **and** `--token`, a name that is not a KDB user may still log in with the token. Failed logins are slowed down like HTTP logins.
- Clients must use `sslmode=disable` or `sslmode=prefer` (the default of most drivers). `sslmode=require` fails, because there is no TLS.
## Connection settings
| Setting | Value |
|---|---|
| Host | `127.0.0.1` |
| Port | `8832` |
| User | any name (for example `app`) |
| Password | the `--token` value |
| Database | a KDB database name, like `db=` in the HTTP API. Empty, `postgres` or the user name means the **default** database. |
A connection string:
```text
postgres://app:my-long-secret@127.0.0.1:8832/market?sslmode=disable
```
## Go (pgx)
This is how the NChartPro backend connects. Nothing special is needed.
```go
package main
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, "postgres://app:my-long-secret@127.0.0.1:8832/market?sslmode=disable")
if err != nil {
panic(err)
}
defer pool.Close()
// Parameters $1, $2, ... work. The values never become part of the SQL text.
_, err = pool.Exec(ctx, "INSERT INTO securities (symbol, name, sector) VALUES ($1, $2, $3)",
"COAST", "Coastal Bank", "Banking")
if err != nil {
panic(err)
}
rows, err := pool.Query(ctx,
"SELECT time, close, volume FROM ochl_day WHERE symbol = $1 AND time >= $2 ORDER BY time",
"CITY", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
if err != nil {
panic(err)
}
defer rows.Close()
for rows.Next() {
var t time.Time
var close, volume float64
if err := rows.Scan(&t, &close, &volume); err != nil {
panic(err)
}
fmt.Println(t.Format("2006-01-02"), close, volume)
}
}
```
Everything pgx does works: the pool, `QueryRow`, `pgx.CollectRows` with `RowToStructByName`, `Begin`/`Commit`/`Rollback`, batches (`SendBatch`), and all query modes (the default cached statements, `QueryExecModeSimpleProtocol`, `Exec`, `DescribeExec`, `CacheDescribe`).
The test program `tests/pg_go/main.go` checks all of this against a real server. Its real output:
```text
ok connect and ping (SCRAM-SHA-256 login)
ok version() = "PostgreSQL 14.0 (KDB 0.2.0-dev), compatible server", database "default"
ok create table
ok insert with parameters (int, text, float, numeric, bool, time, NULL)
ok select with a parameter: {ID:1 Email:asha@example.com Balance:10.5 Active:true Created:2026-09-11 05:15:00.123 +0000 UTC} price 512.40
ok pgx.CollectRows into structs: 2 rows
ok LIKE, IN and LIMIT parameters
ok transactions: rollback and commit
ok unique violation: SQLSTATE 23505 (a row with id='1' already exists in 'users'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it)
ok unknown table 42P01; error inside a transaction, then rollback
ok batch of 4 statements
ok query modes: simple protocol, exec, describe exec, cache describe
ALL OK
```
Run it yourself:
```powershell
kdb serve --data-dir D:\kdb-pg-test --port 9950 --pg-port 8850 --token secret-token
# in another window:
cd tests\pg_go
go run . "postgres://app:secret-token@127.0.0.1:8850/default"
```
## Python
`psycopg` (version 3) and `psycopg2` use the normal Postgres protocol, so they work the same way. The pure-Python driver **pg8000** was tested (`tests/pg_python/check_pg8000.py`):
```python
import pg8000.native
con = pg8000.native.Connection("app", host="127.0.0.1", port=8832,
database="market", password="my-long-secret")
rows = con.run("SELECT time, close FROM ochl_day WHERE symbol = :s ORDER BY time", s="CITY")
for t, close in rows:
print(t, close)
con.close()
```
With psycopg:
```python
import psycopg
with psycopg.connect("host=127.0.0.1 port=8832 dbname=market user=app password=my-long-secret sslmode=disable") as con:
for row in con.execute("SELECT symbol, close FROM ochl_day WHERE close > %s", (500,)):
print(row)
```
(psycopg was not run on the test PC: it has no build for its Python version yet.)
## Node (pg)
```javascript
const { Client } = require('pg');
const client = new Client({
host: '127.0.0.1', port: 8832, user: 'app',
password: 'my-long-secret', database: 'market',
});
await client.connect();
const res = await client.query('SELECT symbol, close FROM ochl_day WHERE symbol = $1', ['CITY']);
console.log(res.rows);
await client.end();
```
This was tested. **Note about times:** `node-pg` reads a `timestamp` (without time zone) as your computer's **local** time. On a PC set to Nepal time (+05:45), 05:15 UTC comes back as 23:30 of the day before. Start the server with `--pg-timestamptz` and times come back right.
Numbers: `node-pg` gives `LONG` and `DECIMAL` values as strings (`'182390'`, `'512.40'`), as with PostgreSQL.
## psql
```powershell
$env:PGPASSWORD = "my-long-secret"
psql "host=127.0.0.1 port=8832 dbname=market user=app sslmode=disable"
```
```text
market=> SELECT symbol, close FROM ochl_day WHERE symbol = 'CITY' LIMIT 2;
market=> BEGIN;
market=*> INSERT INTO brokers VALUES (99, 'Test Broker');
market=*> ROLLBACK;
```
Plain SQL works. KDB also answers the Postgres catalog (`pg_catalog`, `information_schema`), so `\l` (databases), `\dt` (tables), `\di` (indexes) and `\du` work. A full `\d ochl_day` still fails on one of the queries psql sends; use `SHOW COLUMNS FROM ochl_day;` and `SHOW CREATE TABLE ochl_day;` instead. `\du` shows one user, `kdb`: use `SHOW USERS;` for the real list.
`\copy` works too, because psql sends it as `COPY ... FROM STDIN` (see [Loading many rows: COPY](#loading-many-rows-copy)).
(psql is not installed on the test PC, so this example was not run.)
## DBeaver
1. **New Database Connection** → **PostgreSQL**.
2. Host `127.0.0.1`, port `8832`, database `market`, user `app`, password = the token.
3. **Driver properties**: set `sslmode` to `disable`.
4. **Connection settings → Initialization**: turn off "Show all databases".
5. Open an **SQL editor** and run KDB SQL.
The SQL editor and the result grid work. The **Database Navigator** (the tree of schemas and tables) reads the Postgres catalog, which KDB answers: it lists the tables, their columns with types and defaults, indexes and constraints. Sizes stay empty, and row counts are not known.
(DBeaver was not run on the test PC.)
## Types
| KDB type | Postgres type (OID) | Go (pgx) |
|---|---|---|
| `BOOL` | `bool` (16) | `bool` |
| `BYTE`, `SHORT` | `int2` (21) | `int16` |
| `INT` | `int4` (23) | `int32` |
| `LONG` | `int8` (20) | `int64` |
| `FLOAT` | `float4` (700) | `float32` |
| `DOUBLE` | `float8` (701) | `float64` |
| `DECIMAL(s)` | `numeric` (1700) | `pgtype.Numeric`, `float64`, `string` |
| `DATE` | `date` (1082) | `time.Time` |
| `TIMESTAMP` | `timestamp` (1114), or `timestamptz` (1184) with `--pg-timestamptz` | `time.Time` |
| `SYMBOL`, `STRING` | `text` (25) | `string` |
| `JSON` | `json` (114) | `string`, `[]byte`, a struct |
| `BYTES` | `bytea` (17) | `[]byte` |
Values go both ways in **text** and **binary** form. A driver picks the form; you do not have to.
**Parameters get their type from where you use them.** In `WHERE volume > $1`, `$1` is `LONG` because `volume` is `LONG`. In `INSERT INTO t (a, b) VALUES ($1, $2)`, each parameter gets its column's type. A parameter KDB cannot place (`SELECT $1`) has no type; the driver then sends it as text.
**Computed columns.** Before a query runs, KDB works out the type of each result column. For columns, `count(*)`, `sum`, `avg`, arithmetic and most functions it knows the type. For a few (a saved function, say) it runs the query once with empty values to learn it. If a type is still unknown, the column is `text`. When Go then says `cannot scan text into *float64`, add a cast: `SELECT my_func(close)::DOUBLE AS x ...`.
## Times
KDB keeps a `TIMESTAMP` as one exact moment in **UTC** (chapter 4).
By default it is sent as Postgres `timestamp` (without time zone), like QuestDB does. With this type, drivers **drop the time zone** of a value you send:
- pgx: a Go `time.Time` in a local time (for example 11:00 +05:45) is stored as 11:00 **UTC**. Always send **UTC** times: `t.UTC()`.
- node-pg: times you read come back in the computer's local time zone (see above).
Start the server with **`--pg-timestamptz`** to send `TIMESTAMP` as `timestamptz`. Then every driver keeps the exact moment, in any time zone. pgx then gives `time.Time` values in the local time zone of the Go program (the same moment). Choose one setting and keep it.
Times have **milliseconds**. Microseconds from a driver are cut.
## Transactions
`BEGIN`, `COMMIT` and `ROLLBACK` work across messages, one transaction per connection. Postgres forms are accepted too: `BEGIN ISOLATION LEVEL ...`, `START TRANSACTION`, `END`, `ABORT`. (Every KDB transaction is serializable; the level is ignored.)
When a statement fails inside a transaction, the transaction is **aborted**, like in PostgreSQL. Every next statement gets error `25P02` until you send `ROLLBACK` (a `COMMIT` then also rolls back, and its tag is `ROLLBACK`). pgx does this for you: `tx.Rollback(ctx)`.
- **One writer per table** (chapter 15). While one connection has an open transaction, a change of one of its tables from another connection waits up to 5 seconds, then fails with `55P03` (`table 'x' is busy`). Changes of other tables go on; a deadlock fails at once with `40P01`. Reads do not wait. Keep transactions short.
- A transaction with no statement for **30 seconds** is rolled back. The connection then gets `25P02` until it sends `ROLLBACK`.
- When a connection closes, its open transaction is rolled back.
`INSERT`, `UPDATE` and `DELETE` with `RETURNING` give rows, like a `SELECT` (in Go: `pool.QueryRow(ctx, "INSERT ... RETURNING id", ...).Scan(&id)`).
## Errors
Errors carry a **SQLSTATE** code, so `errors.As(err, &pgErr)` and `pgErr.Code` work in Go:
| Code | Meaning |
|---|---|
| `42601` | Syntax error (the message has `line X, column Y`; the error also has the position) |
| `42P01` | Unknown table or view |
| `42703` | Unknown column |
| `23505` | Unique key used twice (PRIMARY KEY, UNIQUE) |
| `23503` | REFERENCES: the row it points to is missing, or is still used |
| `23502` | NOT NULL column without a value |
| `23514` | CHECK constraint failed |
| `25P02` | The transaction failed before (or was idle too long); send ROLLBACK |
| `55P03` | Busy: another transaction held the table for 5 seconds; try again |
| `40P01` | Deadlock: two transactions waited for each other; this one was rolled back. Run it again |
| `42501` | The user's role or table rights do not allow this statement |
| `57014` | The statement was stopped: statement timeout, a CancelRequest, server shutdown |
| `54000` | The query keeps too much in memory (`--max-result-memory`, `--max-groups`) |
| `25006` | The database or the server is read-only |
| `28P01` | Wrong password |
| `3D000` | Unknown database |
| `53300` | Too many connections (at most 100) |
| `XX000` | Anything else (read the message) |
## What drivers send, and KDB answers
Drivers send a few Postgres commands that KDB SQL does not have. KDB answers them itself:
| Command | Answer |
|---|---|
| `SET name = value` / `SET name TO value` | accepted and remembered (`client_encoding` must be UTF8) |
| `SET statement_timeout = 5000` (or `'5s'`), `SET safe_updates = on` | KDB uses them (chapter 21); a user who is not an admin can only make the timeout shorter than the server's |
| CancelRequest (Ctrl+C in psql, a cancelled context in pgx) | stops the running statement of that connection: `57014` |
| `SHOW server_version`, `SHOW TimeZone`, ... | the setting; `server_version` is `14.0 (KDB ...)` |
| `SELECT version()` | `PostgreSQL 14.0 (KDB 0.2.0-dev), compatible server` |
| `SELECT current_database()` | the KDB database name |
| `SELECT current_user`, `current_schema()` | the user name, `public` |
| `RESET ...`, `DISCARD ALL`, `DEALLOCATE ...` | accepted |
`SHOW TABLES`, `SHOW COLUMNS FROM t` and the other KDB `SHOW` statements still work as KDB statements.
## Loading many rows: COPY
`COPY` is the fast way to load many rows over this port. pgx `CopyFrom` sends it, and so does psql's `\copy`. It loads hundreds of thousands of rows a second, about 1,000 times faster than one `INSERT` per round trip.
| Written as | What it does |
|---|---|
| `COPY t (a, b) FROM STDIN (FORMAT binary)` | what pgx `CopyFrom` sends; the fastest form |
| `COPY t FROM STDIN` | text: tab between values, `\N` for NULL |
| `COPY t FROM STDIN WITH (FORMAT csv, HEADER)` | CSV, with quotes and an optional header line |
| `COPY t TO STDOUT`, `COPY (SELECT ...) TO STDOUT` | reads rows out, in text, CSV or binary |
```go
rows := [][]any{{int64(101), "Everest Capital"}, {int64(102), "Summit Traders"}}
n, err := pool.CopyFrom(ctx, pgx.Identifier{"brokers"}, []string{"id", "name"}, pgx.CopyFromRows(rows))
```
- **All or nothing.** One bad row (a wrong type, a key used twice, an empty `NOT NULL` column) fails the whole `COPY`, and nothing is written. The error names the row: `COPY brokers: row 301: ...`.
- Inside `BEGIN` ... `COMMIT` the rows join the transaction; `ROLLBACK` removes them.
- A file name instead of `STDIN` or `STDOUT` is refused: KDB never reads or writes files on the server for a client.
## Not supported yet
- **TLS** (use localhost or an SSH tunnel).
- A few parts of the **Postgres catalog**: a full psql `\d ` (use `SHOW COLUMNS`), sizes and row counts in the catalog tables, the real user list in `pg_roles` (use `SHOW USERS`).
- **Postgres SQL** that KDB does not have (for example `::regclass`, `MERGE`). Chapters 22 and 23 list what works.
- `LISTEN`/`NOTIFY` (accepted, but no notifications come), functions calls of the old protocol, `SAVEPOINT`.
- Parameters in `FILL(...)`, `SAMPLE BY` and other places where KDB needs a fixed value. `LIMIT $1` and `OFFSET $1` work.
- A simple query with several statements is not one transaction: statements before an error stay done. Use `BEGIN` ... `COMMIT`.
- `SET ROLE`, `CREATE ROLE`, PostgreSQL's full `GRANT` syntax: KDB has users with roles per database (chapter 16) and `GRANT SELECT|INSERT|UPDATE|DELETE ON TABLE t TO user` (chapter 21); no column or row rights.
- Users with **two-factor login** (chapter 21) cannot log in on this port (a driver cannot ask for the code). Give programs their own user.
---
# 15. Transactions and accounting
- Category: Tutorial
## What you will learn
- Keep users (register and login) and money (accounts and a ledger) in KDB.
- Stop bad data with **CHECK constraints** (for example: no balance below zero).
- Move money with a **transaction**: all steps are saved, or none.
- Get a new id back with **RETURNING**.
- Keep a transaction open over **many requests** (kdb shell, HTTP, PostgreSQL protocol).
- What happens when many programs write at the same time, and what the **timeouts** do.
- What happens on an **error** and after a **crash**.
The examples use a new, empty database `market`. Each example is a file in `examples/15-transactions-and-accounting/`. The results are real.
## The rules in short
| Rule | What it means for you |
|---|---|
| One writer per table | While one transaction is open, other changes of its tables **wait**; changes of other tables go on. Two transfers never mix. |
| Reads do not wait | A `SELECT` of another program sees the data **as it was before** the open transaction. It never sees half of a transfer. |
| All or nothing | `COMMIT` saves all changes of the transaction in one log write. An error saves nothing. |
| Crash safe | After a crash or power cut, every transaction that got `COMMIT` OK is there. A transaction without `COMMIT` is not there at all. |
| CHECK | A row that breaks a `CHECK` is never written. |
## The tables
```sql
-- Users who can log in, their accounts, and every movement of money.
CREATE TABLE users (
id INT PRIMARY KEY AUTO,
email STRING NOT NULL UNIQUE,
password_hash STRING NOT NULL,
created TIMESTAMP DEFAULT now()
);
CREATE TABLE accounts (
id INT PRIMARY KEY AUTO,
user_id INT NOT NULL REFERENCES users(id),
balance DECIMAL(2) NOT NULL DEFAULT 0 CHECK (balance >= 0)
);
CREATE TABLE ledger (
id LONG PRIMARY KEY AUTO,
from_account INT NOT NULL REFERENCES accounts(id),
to_account INT NOT NULL REFERENCES accounts(id),
amount DECIMAL(2) NOT NULL,
created TIMESTAMP DEFAULT now(),
CONSTRAINT positive_amount CHECK (amount > 0),
CONSTRAINT two_accounts CHECK (from_account != to_account)
);
```
```text
table 'users' created
table 'accounts' created
table 'ledger' created
```
Some choices here are important for money:
- **`DECIMAL(2)`** for money, never `DOUBLE`. A `DECIMAL` is exact: `0.10 + 0.20` is `0.30`.
- **`NOT NULL`** on every money column. A `CHECK` lets `NULL` pass, so use both.
- The **ledger** keeps every movement. Never update or delete ledger rows. To undo a transfer, write a new transfer back.
## Register and login
KDB has the functions `password_hash(password)` and `password_verify(password, hash)`. They keep only a salted, slow hash of the password, never the password itself.
```sql
-- Register two users. RETURNING gives the new AUTO id back.
INSERT INTO users (email, password_hash) VALUES ('ram@example.com', password_hash('ram-secret-1')) RETURNING id, email;
INSERT INTO users (email, password_hash) VALUES ('sita@example.com', password_hash('sita-secret-2')) RETURNING id, email;
```
```text
id | email
---+----------------
1 | ram@example.com
(1 row, 0.9 ms)
1 row(s) inserted
id | email
---+-----------------
2 | sita@example.com
(1 row, 0.4 ms)
1 row(s) inserted
```
`UNIQUE` refuses a second user with the same email:
```sql
-- The same email again: UNIQUE refuses it.
INSERT INTO users (email, password_hash) VALUES ('ram@example.com', password_hash('other')) RETURNING id;
```
```text
error: column 'email' must be unique: 'ram@example.com' is already used
```
Login: find the user by email and check the password.
```sql
-- Login: find the user by email, check the password against the saved hash.
SELECT id, password_verify('ram-secret-1', password_hash) AS password_ok
FROM users WHERE email = 'ram@example.com';
SELECT id, password_verify('wrong password', password_hash) AS password_ok
FROM users WHERE email = 'ram@example.com';
```
```text
id | password_ok
---+------------
1 | true
(1 row, 53.8 ms)
id | password_ok
---+------------
1 | false
(1 row, 50.7 ms)
```
`password_verify` is slow **on purpose** (about 50 ms), so an attacker cannot try many passwords fast. In your app, give the same message for "no such email" and "wrong password".
In a real app, send the email and the password as **parameters** (`$1`, `$2` in the PostgreSQL protocol), never glued into the SQL text.
## RETURNING: get the new id
`INSERT`, `UPDATE` and `DELETE` can end with `RETURNING` and a list of columns or expressions. You get the rows back like from a `SELECT`:
```sql
-- One account for each user. Ram starts with 1000.00.
INSERT INTO accounts (user_id, balance) VALUES (1, 1000), (2, 0) RETURNING id, user_id, balance;
```
```text
id | user_id | balance
---+---------+--------
1 | 1 | 1000.00
2 | 2 | 0.00
(2 rows, 2.0 ms)
2 row(s) inserted
```
| Statement | `RETURNING` gives |
|---|---|
| `INSERT` | the new rows, with `AUTO` ids and `DEFAULT` values filled in |
| `UPDATE` | the rows after the change |
| `DELETE` | the deleted rows |
`RETURNING *` gives all columns. If the `RETURNING` list has an error (an unknown column), nothing is written.
## CHECK constraints
A `CHECK` is a rule for every row. KDB checks it on `INSERT`, `INSERT OR REPLACE`, `UPDATE`, `InsertRows` and the line protocol. A row that makes the condition **false** is not written, and the statement fails:
```sql
-- A balance below zero breaks the CHECK constraint: nothing is written.
UPDATE accounts SET balance = balance - 5000 WHERE id = 1;
```
```text
error: CHECK constraint 'accounts_balance_check' of table 'accounts' failed: balance >= 0 is false for the row id='1', balance='-4000.00'
```
The message names the **constraint**, the **table** and the **row** (its key and the columns the rule uses).
Two ways to write a `CHECK`:
```sql
balance DECIMAL(2) NOT NULL CHECK (balance >= 0) -- after a column
CONSTRAINT positive_amount CHECK (amount > 0) -- after the columns, with a name
```
Without a name, KDB makes one: `table_column_check` (for a column) or `table_check`. `SHOW CREATE TABLE` shows every `CHECK` with its name:
```sql
SHOW CREATE TABLE accounts;
```
```text
create_sql
"CREATE TABLE accounts (
id INT PRIMARY KEY AUTO,
user_id INT NOT NULL REFERENCES users(id),
balance DECIMAL(2) NOT NULL DEFAULT 0,
CONSTRAINT accounts_balance_check CHECK (balance >= 0)
)"
```
Add or remove a rule later:
```sql
ALTER TABLE accounts ADD CONSTRAINT max_balance CHECK (balance <= 10000000);
ALTER TABLE accounts DROP CONSTRAINT max_balance;
```
`ADD` first reads every row of the table. If a row already breaks the rule, the rule is refused, and the message names that row.
| CHECK rule | Detail |
|---|---|
| NULL | `NULL` passes (the result is not false). Add `NOT NULL` when the value must be there. |
| What it can use | The columns of the same row, operators, `CASE`, built-in functions (`length(email) > 3`, `amount BETWEEN 1 AND 100000`). |
| What it cannot use | Other rows, subqueries, aggregates (`sum`), window functions, saved functions, computed columns. |
| Columns | A column used by a `CHECK` cannot be dropped. `RENAME COLUMN` also renames it inside the `CHECK`. |
## A transfer
Moving money needs **four** changes: take money from one account, give it to the other, write the ledger row, and save. They must happen together.
```sql
-- Move 250.50 from account 1 to account 2: all steps together, or none.
BEGIN;
SELECT id, balance FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 250.50 WHERE id = 1;
UPDATE accounts SET balance = balance + 250.50 WHERE id = 2;
INSERT INTO ledger (from_account, to_account, amount) VALUES (1, 2, 250.50) RETURNING id;
COMMIT;
SELECT id, balance FROM accounts ORDER BY id;
```
```text
transaction started
id | balance
---+--------
1 | 1000.00
2 | 0.00
(2 rows, 0.2 ms)
1 row(s) updated
1 row(s) updated
id
--
1
(1 row, 0.0 ms)
1 row(s) inserted
transaction committed: 3 change(s) saved
id | balance
---+--------
1 | 749.50
2 | 250.50
(2 rows, 0.0 ms)
```
Why each part is there:
| Part | Why |
|---|---|
| `BEGIN` | Starts the transaction. Every table it changes (or reads with `FOR UPDATE`) is locked from then on until `COMMIT` or `ROLLBACK`: no other program can change those tables meanwhile. |
| `SELECT ... FOR UPDATE` | Reads the balances. Your app can check them (for example, show "not enough money" before trying). `FOR UPDATE` tells the reader: these rows are read to be changed. In KDB it locks the table `accounts` at once, so the balances you just read cannot change before your `UPDATE`. Other databases need it too: write it, and your SQL stays correct everywhere. |
| `balance = balance - 250.50` | Let the database do the math, in one statement. **Do not** read the balance in your app, subtract there, and write the result back (`SET balance = 749.50`) outside a transaction: two programs could both read 1000.00. |
| `CHECK (balance >= 0)` | Even if your app forgets to check, the database refuses a negative balance. |
| `RETURNING id` | The ledger id, for the receipt. |
| `COMMIT` | Saves all changes with one log write. |
## When something goes wrong
Ram has 749.50 now. He tries to send 900.00:
```sql
-- Ram has 749.50. A transfer of 900.00 fails at the first UPDATE.
BEGIN;
UPDATE accounts SET balance = balance - 900 WHERE id = 1;
UPDATE accounts SET balance = balance + 900 WHERE id = 2;
INSERT INTO ledger (from_account, to_account, amount) VALUES (1, 2, 900) RETURNING id;
COMMIT;
```
```text
transaction started
error: CHECK constraint 'accounts_balance_check' of table 'accounts' failed: balance >= 0 is false for the row id='1', balance='-150.50' (the transaction was rolled back)
```
The first `UPDATE` fails. The whole transaction is **rolled back**: nothing of it is saved. The books are the same as before:
```sql
-- Nothing of the failed transfer was saved. Check the books.
SELECT id, balance FROM accounts ORDER BY id;
SELECT sum(balance) AS total_money FROM accounts;
SELECT from_account, to_account, count(*) AS transfers, sum(amount) AS moved
FROM ledger GROUP BY from_account, to_account ORDER BY from_account;
```
```text
id | balance
---+--------
1 | 749.50
2 | 250.50
(2 rows, 0.2 ms)
total_money
-----------
1000.00
(1 row, 0.1 ms)
from_account | to_account | transfers | moved
-------------+------------+-----------+-------
1 | 2 | 1 | 250.50
(1 row, 0.1 ms)
```
Run queries like these every night. The total must not change (except for money that comes in from outside), and every balance must match its ledger rows.
| What happens | Result |
|---|---|
| A statement fails (CHECK, REFERENCES, UNIQUE, a typing error, ...) | The transaction is rolled back. Nothing is saved. |
| Your app sends `ROLLBACK` | Nothing is saved. |
| The client disconnects before `COMMIT` | Rolled back. |
| No statement for 30 seconds | Rolled back (see [Timeouts](#timeouts)). |
| KDB crashes or the power goes off before `COMMIT` returns | Nothing is saved. |
| `COMMIT` returned OK | Saved, also after a crash or power cut. |
| The disk is full at `COMMIT` | `COMMIT` returns an error, and nothing is saved. |
## A transaction over many requests
A transaction can go over many requests when the program keeps a **session**:
- **kdb shell**: one session for the whole shell.
- **HTTP**: `POST /session`, then the header `X-KDB-Session` on every `/exec`.
- **PostgreSQL protocol** (psql, and drivers for Python, Go, Node, Java, .NET): every connection is a session. Use `BEGIN` and `COMMIT` as usual.
Inside a session there is one more rule: **after an error, the session accepts only `ROLLBACK`**. Your next statements do not run by mistake outside the transaction:
```powershell
# In kdb shell, a transaction goes over many lines. After an error, only ROLLBACK is accepted.
@"
BEGIN;
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
UPDATE accounts SET balance = balance - 10000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1 WHERE id = 2;
ROLLBACK;
SELECT id, balance FROM accounts ORDER BY id;
.exit
"@ | kdb shell market
```
```text
KDB 0.2.0-dev shell on market
End statements with ';'. Type .help for commands, .exit to quit.
kdb> transaction started
kdb> 1 row(s) updated
kdb> error: CHECK constraint 'accounts_balance_check' of table 'accounts' failed: balance >= 0 is false for the row id='1', balance='-9250.50' (the transaction was rolled back; send ROLLBACK to end it)
kdb> error: the transaction of this session was rolled back (a statement in it failed); nothing of it was saved. Send ROLLBACK to end it, then start again
kdb> transaction rolled back (it was already rolled back: a statement in it failed)
kdb> id | balance
---+--------
1 | 749.50
2 | 250.50
(2 rows, 0.1 ms)
kdb>
```
The third line (`+ 1` for account 2) was **not** run: it would have given money without taking it anywhere. `COMMIT` in this state also fails (`COMMIT saved nothing: ...`), so your app cannot think the transfer was saved.
### Over HTTP
```text
POST /session -> {"session": "", "idle_timeout_ms": 600000}
POST /exec (header X-KDB-Session: ) BEGIN, UPDATE, ..., COMMIT
DELETE /session/ -> {"session": "", "closed": true} an open transaction is rolled back
```
PowerShell:
```powershell
$url = "http://127.0.0.1:9900"
$s = Invoke-RestMethod -Method Post "$url/session"
$h = @{ "X-KDB-Session" = $s.session }
$r = Invoke-RestMethod -Method Post "$url/exec" -Headers $h -Body "BEGIN"
$r.results[0].message
$r = Invoke-RestMethod -Method Post "$url/exec" -Headers $h -Body "UPDATE accounts SET balance = balance - 100 WHERE id = 1 RETURNING balance"
"balance inside the transaction: " + $r.dataset[0][0]
$r = Invoke-RestMethod -Method Post "$url/exec" -Body "SELECT balance FROM accounts WHERE id = 1"
"balance seen by another request: " + $r.dataset[0][0]
$r = Invoke-RestMethod -Method Post "$url/exec" -Headers $h -Body "UPDATE accounts SET balance = balance + 100 WHERE id = 2; INSERT INTO ledger (from_account, to_account, amount) VALUES (1, 2, 100) RETURNING id"
"ledger id: " + $r.results[1].dataset[0][0]
$r = Invoke-RestMethod -Method Post "$url/exec" -Headers $h -Body "COMMIT"
$r.results[0].message
$r = Invoke-RestMethod -Method Post "$url/exec" -Body "SELECT balance FROM accounts WHERE id = 1"
"balance after COMMIT: " + $r.dataset[0][0]
$r = Invoke-RestMethod -Method Delete "$url/session/$($s.session)"
"session closed: " + $r.closed
```
```text
transaction started
balance inside the transaction: 649.50
balance seen by another request: 749.50
ledger id: 2
transaction committed: 3 change(s) saved
balance after COMMIT: 649.50
session closed: True
```
The request **without** the session header saw 749.50: the open transaction was not committed yet.
| HTTP session rule | Detail |
|---|---|
| Id | Random and long, so nobody can guess another client's session. Keep it secret like a password. |
| Unused | A session without a request for 10 minutes is closed (its open transaction was already rolled back after 30 seconds). |
| Unknown or closed id | `/exec` answers `404` with `session '...' does not exist`. |
| Busy | `table 'x' is busy`, `database is busy` and `deadlock` are answered with `503`: try again a moment later. |
## Many programs at the same time
KDB locks **tables**, not the whole database. A writer locks every table it uses until its statement ends; inside `BEGIN` ... `COMMIT` it keeps them until the transaction ends. Two transfers can never mix, and every transaction sees the data exactly as the one before it left it (this is called *serializable*).
- **Writers of the same table wait.** While session A has an open transaction that changed `accounts`, session B's `UPDATE`, `INSERT`, `DELETE` or `CALL` on `accounts` **waits** until A ends. Writers wait in a queue: the one that came first goes first. Line-protocol writes and `InsertRows` into that table wait too.
- **Writers of other tables do not wait.** A price feed into `live_data` goes on while a transfer holds `accounts` and `ledger`.
- **What is locked:** the tables a statement changes and reads, and what it sets off: tables its triggers and procedures use, and tables linked to it by `REFERENCES`.
- **Deadlock:** when A waits for a table B holds, and B then needs a table A holds, one of them gets `deadlock: ...` at once and is rolled back. Run it again. Use the tables in the same order in every transaction (`accounts`, then `ledger`), and it cannot happen.
- **Schema changes** (`CREATE`, `ALTER`, `DROP`, `TRUNCATE`) lock every table. They wait until no transaction is open.
- **Readers do not wait for an open transaction.** Session B's `SELECT` runs at once and sees the data **without** A's changes. Inside A, a `SELECT` sees A's own changes.
- A normal statement without `BEGIN` is its own small transaction, so it also waits for an open transaction that holds its table.
- **Commits share the disk.** Writers that commit at the same moment share one wait for the disk (group commit). Each one is still told "committed" only when its changes are on the disk.
A read can still wait for a writer's **running** statement, usually a millisecond. A long `CALL` or a slow trigger body can make reads wait for seconds. `kdb serve --reader-yield on` (in Docker: `KDB_READER_YIELD=on`) lets reads in between the statements of such a writer: they read the last committed rows at once.
A transfer takes about a millisecond, so waiting is short. It becomes a problem only when a transaction stays open for a long time. **Never** keep a transaction open while you wait for a person (a click, a form, an SMS code). Read first, ask the person, then do `BEGIN ... COMMIT` quickly.
## Timeouts
| Option (`DatabaseOptions`) | Default | What happens |
|---|---|---|
| `lock_timeout_ms` | 5000 (5 s) | A writer that waited this long for a table gets the error `table 'x' is busy: another transaction has held it for N ms; waited 5000 ms ...` (a schema change: `database is busy: ...`). Nothing of its statement was done. Try again. |
| `idle_transaction_timeout_ms` | 30000 (30 s) | A transaction with no statement for this long is rolled back. Its session's next statement gets `the transaction of this session was rolled back (it was open without a statement for ...)`. Send `ROLLBACK` and start again. |
In your app:
- On `table 'x' is busy`, `database is busy` or `deadlock`: wait a little (for example 100 ms) and run the **whole** transaction again.
- On any error inside a transaction: send `ROLLBACK`, then show an error or try the whole transaction again. Never continue with the next statement.
## After a crash
Changes of a transaction are written to the log only at `COMMIT`, in one write, and KDB waits until the disk has them. So:
- a transaction that got `COMMIT` OK is there after a crash or a power cut;
- a transaction without `COMMIT` leaves **no trace**, not even half of a transfer.
KDB tests this all the time. `test_accounting` runs 8 threads doing 2,000 random transfers (with errors, rollbacks and clients that go away) while readers check the total at every moment, and copies the database folder in the middle of open transactions and opens the copies. `crash_test` kills a program doing transfers more than 200 times at random moments and checks every time: the total is the same, no balance is negative, the ledger matches the balances, and every confirmed transfer is there.
## Checklist for an accounting app
1. Money columns: `DECIMAL(2) NOT NULL`, with `CHECK` rules for what must never happen (`balance >= 0`, `amount > 0`).
2. Keep a ledger. Never update or delete its rows; correct a mistake with a new row.
3. Every transfer: `BEGIN`, `SELECT ... FOR UPDATE`, `UPDATE ... SET balance = balance ± amount`, `INSERT INTO ledger ... RETURNING id`, `COMMIT`.
4. On any error: `ROLLBACK`. On `table 'x' is busy`, `database is busy` or `deadlock`: try the whole transaction again.
5. Keep transactions short. No waiting for people inside a transaction.
6. Use parameters (`$1`), not SQL glued together from user input.
7. Store `password_hash(...)`, check with `password_verify(...)`.
8. Check the books every night (total, and balances against the ledger), and make a backup (chapter 9).
## Try it yourself
1. Add a rule so that one transfer can move at most 50,000.00.
2. Ram sends 100.00 to himself (account 1 to account 1). What happens?
3. Your app does: `SELECT balance FROM accounts WHERE id = 1` (749.50), then without `BEGIN`: `UPDATE accounts SET balance = 649.50 WHERE id = 1`. Why is this wrong, even with the `CHECK`?
Answers
1. `ALTER TABLE ledger ADD CONSTRAINT max_amount CHECK (amount <= 50000);`
2. The ledger `INSERT` breaks `CHECK (from_account != to_account)` (`two_accounts`). The transaction is rolled back, so the two `UPDATE`s before it are undone too.
3. Between the `SELECT` and the `UPDATE`, another program can change the balance (for example, a transfer in or out). Your `UPDATE` then writes an old number and the other change is lost. Use `balance = balance - 100` inside `BEGIN ... COMMIT`.
---
Back to the index. Also read the [known limitations](known-limitations.md).
---
# 16. Users, passwords and security
- Category: Tutorial
## What you will learn
- **Part A.** How your app lets people register and log in, with KDB as the database: a users table, `password_hash`, `password_verify`, session tokens.
- **Part B.** Database users: who may use the KDB server itself, and what each one may do (roles).
- **Part C.** A checklist for a small VPS on the internet.
Chapter 15 uses the same register and login in a small accounting example. Part A is about the people who use **your app** (customers, staff). Part B is about the **programs and people that connect to KDB** (your app's backend, you in the web console, a report script). They are two different things. Most apps need both.
---
## Part A: users of your app
### Never store plain passwords
Do not put the password itself in a table. If somebody gets a copy of the database (a stolen backup, a bug, an old disk), they get every password. Many people use the same password for email and bank. So a leak hurts them far beyond your app.
Store a **password hash** instead:
- A hash is made from the password, but the password cannot be made back from the hash.
- KDB's `password_hash()` is **slow on purpose** (about 0.05 to 0.4 seconds). One login is still fast. But an attacker who stole the hashes can only try a few guesses per second, not billions.
- Each hash has a **random salt**. Two users with the same password get different hashes.
| Do | Do not |
|---|---|
| `password_hash('the password')` when the user registers | store the password, or `sha256(password)` (far too fast to guess) |
| `password_verify('typed password', password_hash)` when the user logs in | compare hashes with `=` (every hash has its own salt, so it never matches) |
| give the user a random session token after login | send the password again with every request |
### The tables
```sql
CREATE TABLE app_users (
id INT PRIMARY KEY AUTO,
email STRING NOT NULL UNIQUE,
password_hash STRING NOT NULL,
created_at TIMESTAMP DEFAULT now()
);
CREATE TABLE app_sessions (
token_hash STRING PRIMARY KEY,
user_id INT NOT NULL REFERENCES app_users(id),
expires_at TIMESTAMP NOT NULL
);
```
- `email ... UNIQUE`: the same email cannot register twice.
- `app_sessions` keeps the **SHA-256 of the token**, not the token. Then a leaked copy of this table cannot be used to log in either.
### Register
Your backend receives the email and the password (over HTTPS). It runs:
```sql
INSERT INTO app_users (email, password_hash) VALUES ('sita@example.com', password_hash('correct horse battery'));
SELECT id, email, password_hash FROM app_users;
```
```text
1 row(s) inserted
id | email | password_hash
---+------------------+-------------------------------------------------
1 | sita@example.com | $pbkdf2-sha256$i=600000$TJZOjybZUId98CTZjROu0...
(1 row, 0.1 ms)
```
The hash looks like `$pbkdf2-sha256$i=600000$$`. It says how it was made (PBKDF2 with SHA-256, 600,000 rounds, as OWASP advises), so KDB can check it later, even if the default changes.
KDB hashes the password **before** the `INSERT` locks the table, so other writers of `users` do not wait for the slow hash. The statement KDB shows back has the hash, not the password, and the console's history saves `password_hash('***')`.
If the email is already used, the `INSERT` fails because of `UNIQUE`. Tell the user "this email is already registered".
### Log in
```sql
SELECT id FROM app_users
WHERE email = 'sita@example.com' AND password_verify('correct horse battery', password_hash);
```
```text
id
--
1
(1 row, 53.5 ms)
```
With a wrong password there is no row:
```sql
SELECT id FROM app_users
WHERE email = 'sita@example.com' AND password_verify('wrong guess', password_hash);
```
```text
id
--
(0 rows, 52.2 ms)
```
Rules for the login:
- Call `password_verify` **once** per login. It is slow on purpose (see the time above).
- Show the same message for "no such email" and "wrong password", for example "wrong email or password". Then nobody can find out which emails are registered.
- Count failed logins in your app (per email and per IP address) and wait longer after each failure. KDB does this for its own logins (Part B), but not for your app's table.
- `password_verify` gives `false` for a damaged hash. It never gives an error. `NULL` in gives `NULL` out.
### Sessions: stay logged in
After a good login, make a random token and keep its hash:
```sql
SELECT random_token() AS token;
```
```text
token
-------------------------------------------
gag86m0anriIibqzs8qzIZSay4l6JKe8vUOI1WqeyJ8
(1 row, 0.0 ms)
```
`random_token()` gives 32 random bytes from the operating system as URL-safe text (43 characters). Your backend sends it to the browser as a cookie (`HttpOnly; Secure; SameSite=Lax` or `Strict`) and saves its hash with an expiry time:
```sql
INSERT INTO app_sessions VALUES (sha256('mR3v7yJ2nqKx0Qe8i4uX1t9LZbF5cH6aWgD2sP0oVkE'), 1, dateadd('d', 7, now()));
```
On **every request**, your backend finds the user of the token:
```sql
SELECT u.id, u.email
FROM app_sessions s JOIN app_users u ON u.id = s.user_id
WHERE s.token_hash = sha256('mR3v7yJ2nqKx0Qe8i4uX1t9LZbF5cH6aWgD2sP0oVkE') AND s.expires_at > now();
```
```text
id | email
---+-----------------
1 | sita@example.com
(1 row, 0.1 ms)
```
This is fast: `sha256()` is not slow like `password_hash()`.
Log out:
```sql
DELETE FROM app_sessions WHERE token_hash = sha256('mR3v7yJ2nqKx0Qe8i4uX1t9LZbF5cH6aWgD2sP0oVkE');
```
Delete old sessions now and then: `DELETE FROM app_sessions WHERE expires_at < now();`
### Change a password, reset a password
- **Change** (the user knows the old one): check the old one with `password_verify`, then `UPDATE app_users SET password_hash = password_hash('new password') WHERE id = 1;` and delete the user's other sessions.
- **Reset** ("forgot password"): make a `random_token()`, save `sha256(token)` with a short expiry (for example 30 minutes) in a table like `app_sessions`, and email a link with the token. When the link is used, check it like a session, set the new password, and delete the reset row.
### Always use parameters
Never build SQL by gluing user text into it: `"... WHERE email = '" + email + "'"` lets a user write SQL (SQL injection). Use your driver's parameters (`$1`, `$2` with the PostgreSQL protocol), or put the text through a function that doubles every `'`.
### The functions
| Function | Gives | Use |
|---|---|---|
| `password_hash(text)` | `'$pbkdf2-sha256$i=600000$...'` | store a password (slow on purpose) |
| `password_hash(text, iterations)` | the same with 1,000 to 10,000,000 rounds | more rounds are slower and safer |
| `password_verify(text, hash)` | `true` / `false` | check a password; `false` for a damaged hash |
| `sha256(text)` | 64 hex characters | hash a token, a file name, ... (fast) |
| `hmac_sha256(key, text)` | 64 hex characters | sign a message with a secret key (webhooks) |
| `random_bytes(n)` | n random bytes as hex (n = 1 to 1024) | secrets, codes |
| `random_token()`, `random_token(n)` | n random bytes (default 32) as URL-safe base64 | session tokens, reset links, API keys |
| `gen_random_uuid()` | `'0465d47f-8194-4c94-a372-eda3b70504a0'` | random ids (UUID version 4) |
```sql
SELECT sha256('abc') AS hex, hmac_sha256('key', 'message') AS mac, gen_random_uuid() AS id, random_bytes(8) AS bytes;
```
```text
hex | mac | id | bytes
-------------------------------------------------+--------------------------------------------------+--------------------------------------+-----------------
ba7816bf8f01cfea414140de5dae2223b00361a396177... | 6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011... | 0465d47f-8194-4c94-a372-eda3b70504a0 | 6614740117d2ce39
(1 row, 0.0 ms)
```
(`kdb sql` cuts long text in table output; the full values are there.)
The random functions give a new value for every row and every call. `DEFAULT gen_random_uuid()` gives each new row its own id.
**How slow is `password_hash`?** On a PC with SHA instructions in the CPU (most PCs and servers since about 2018) 600,000 rounds take about 0.05 to 0.1 seconds. Without them, about 0.4 seconds. KDB prints nothing about this; `test_crypto` shows the time on your computer. A small VPS with 1 CPU can do about 10 logins per second. That is fine for a normal app.
---
## Part B: database users and roles
### When does KDB ask for a login?
| Server | Login needed? |
|---|---|
| `kdb serve` on `127.0.0.1` (the default), no `--token`, no users | **No.** Only programs on the same computer can connect. This is how KDB worked before. |
| a data folder that has **users** | **Yes**, for every request (except `/health`, `/api/login` and the console page itself). |
| started with `--token ` | **Yes**. The token works like an admin. With users, the token and the users both work. |
| `--bind 0.0.0.0` (or any address that is not 127.x) without `--token` and without users | **Refused**: `kdb serve` does not start (see below). |
`kdb shell` and `kdb sql` open the database files directly. They never ask for a login: whoever can read the data folder can do everything. Protect the folder with the file permissions of the operating system.
### Create the first user
Users belong to the **data folder** (they are kept in a hidden database `_system` in it). A server that opened one database folder by its path (`kdb serve D:\kdb\market`) has no users; use `--data-dir` for users.
The first user must have `ROLE admin`. With the server **stopped**:
```sh
kdb user add admin --role admin --data-dir /srv/kdb/data
```
KDB asks for the password twice (the typed text is not shown). From a script, pipe it in: `echo "the password" | kdb user add admin --role admin`. Never put a password on the command line: other users of the computer can see command lines.
Or with SQL, in `kdb shell` or in the console of a server that has no users yet:
```sql
CREATE USER admin WITH PASSWORD 'Long-Admin-Passw0rd' ROLE admin;
CREATE USER shop_app WITH PASSWORD 'Another-long-secret-42';
GRANT write ON DATABASE shop TO shop_app;
CREATE USER report WITH PASSWORD 'Reports-only-secret-7' ROLE read;
SHOW USERS;
```
```text
user 'admin' created with role admin
user 'shop_app' created with role none
user 'shop_app' has role write on database 'shop'
user 'report' created with role read
name | role | grants | created | changed
---------+-------+------------+--------------------------+-------------------------
admin | admin | NULL | 2026-09-11T16:49:06.180Z | 2026-09-11T16:49:06.180Z
report | read | NULL | 2026-09-11T16:49:06.284Z | 2026-09-11T16:49:06.284Z
shop_app | none | shop=write | 2026-09-11T16:49:06.229Z | 2026-09-11T16:49:06.232Z
(3 rows, 0.0 ms)
```
As soon as the first user exists, a running server asks for a login.
`SHOW USERS` never shows passwords or hashes. A user who is not an admin sees only their own row.
### Roles
| Role | May do |
|---|---|
| `none` | nothing (until a `GRANT`) |
| `read` | `SELECT`, `SHOW`, `EXPLAIN`, CSV export, cursors, `BEGIN`/`COMMIT`/`ROLLBACK`, `/api/info`, list backups |
| `write` | everything of `read`, and `INSERT`, `UPDATE`, `DELETE`, `CALL`, line protocol (`/write`) |
| `admin` | everything of `write`, and `CREATE`/`ALTER`/`DROP` of tables, views, functions, procedures and triggers, `BACKUP`, `CHECK` |
- The **ROLE** of a user (`CREATE USER ... ROLE read`) is for **every** database.
- A **GRANT** is for **one** database: `GRANT write ON DATABASE shop TO shop_app`.
- KDB uses the stronger of the two.
- Only a user whose own ROLE is `admin` may create and drop databases and manage users (`CREATE USER`, `GRANT`, ...). `GRANT admin ON DATABASE shop` gives all rights inside `shop`, but not these.
- Every user may change their own password: `ALTER USER report WITH PASSWORD '...'`.
A refused statement gets an error that says what is missing, and nothing of it runs:
```text
{"error":"INSERT needs role write on database 'shop'; user 'report' has read"} (HTTP 403)
```
Give each program only what it needs. Your app's backend: `write` on its own database (plus `admin` only when it must create tables). A report script: `read`. Keep `admin` for yourself.
### All user statements
```sql
CREATE USER [IF NOT EXISTS] name [WITH] PASSWORD 'secret' [ROLE admin|write|read|none];
ALTER USER name [WITH] PASSWORD 'new secret';
ALTER USER name ROLE read;
DROP USER [IF EXISTS] name;
SHOW USERS;
GRANT read|write|admin|ALL ON DATABASE db TO name; -- ALL = admin
REVOKE [read|write|admin|ALL] ON DATABASE db FROM name; -- removes the grant on that database
GRANT SELECT|INSERT|UPDATE|DELETE|ALL|DEFAULT ON TABLE t TO name; -- chapter 21
REVOKE SELECT|INSERT|UPDATE|DELETE|ALL ON TABLE t FROM name; -- chapter 21
ALTER USER name ENABLE TOTP; -- two-factor login, chapter 21
```
| Rule | Detail |
|---|---|
| Names | letters, digits, `_` and `-`, 1 to 63 characters. `Bob` and `bob` are the same user. |
| Passwords | at least 12 characters, not the user name, at most 1024 bytes. Write `''` for a `'` inside. Older passwords keep working. |
| `REVOKE read` | takes away only a `read` grant. A `write` or `admin` grant stays (KDB says so); use `REVOKE ALL` to take away any grant. |
| Last admin | the last user with `ROLE admin` cannot be dropped or lose that role while other users exist. |
| New password | ends every login (session) of that user at once. |
| `DROP DATABASE` | also removes every `GRANT` on that database. |
The same with the `kdb` tool, when the server is stopped:
| Command | Does |
|---|---|
| `kdb user add [--role admin\|write\|read\|none]` | add a user (the first one is admin) |
| `kdb user passwd ` | set a new password |
| `kdb user role ` | change the role |
| `kdb user list` | names, roles and grants |
| `kdb user remove ` | remove a user |
| `kdb user reset-2fa ` | turn two-factor login off (a lost phone) |
All of them take `--data-dir `. While `kdb serve` runs, they refuse (the server has the users open); use SQL in the console then.
### Logging in
**Web console.** It shows a login form. After the login, the top bar shows the user and a **Log out** button. The login is kept in a cookie that scripts in the page cannot read (`HttpOnly`) and that other web sites cannot use (`SameSite=Strict`). It ends after 12 hours, at **Log out**, when the server restarts, or when the password changes.
**Programs: HTTP Basic.** Send the user name and password with each request. KDB remembers a good password for 10 minutes, so this is not slow:
```sh
curl -s -u report:Reports-only-secret-7 "http://127.0.0.1:9900/exec?db=shop&compat=0&query=SELECT%20id,%20email%20FROM%20app_users"
```
```text
{"query":"SELECT id, email FROM app_users","results":[{"statement":"SELECT id, email FROM app_users","columns":[{"name":"id","type":"INT"},{"name":"email","type":"STRING"}],"dataset":[[1,"sita@example.com"]],"count":1,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":21,"execute_us":172}}],"timings":{"parse_us":21,"execute_us":172}}
```
**Programs: log in once.** `POST /api/login` gives a token. Send it as `Authorization: Bearer `:
```sh
curl -s -i -H "Content-Type: application/json" --data '{"user":"shop_app","password":"Another-long-secret-42"}' http://127.0.0.1:9900/api/login
```
```text
HTTP/1.1 200 OK
Set-Cookie: kdb_login=472axoBncSsNLsieeFTU2Vz8Nomiuun3fYBWjd80u5E; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200
{"user":"shop_app","role":"none","grants":{"shop":"write"},"expires_ms":1789188565554,"token":"472axoBncSsNLsieeFTU2Vz8Nomiuun3fYBWjd80u5E"}
```
**The `--token`** still works as before, and can do everything.
**PostgreSQL clients** (port 8832) log in with the same users (SCRAM-SHA-256). See the PostgreSQL chapter.
### Failed logins
Every failed login is written to the server's output, with the user name and the address (never the password):
```text
2026-09-11 22:34:25 login failed: user 'admin' from 127.0.0.1 (wrong password; 1 failure in a row)
2026-09-11 22:34:26 login failed: user 'admin' from 127.0.0.1 (wrong password; 5 failures in a row); next try allowed in 1 s
```
After 5 failures for one user from one address (or 20 from one address for any users), that address must wait before the next try: 1 second, then 2, 4, 8, ... up to 15 minutes. Meanwhile the answer is HTTP 429:
```text
{"error":"wrong user name or password","login":true} (HTTP 401)
{"error":"too many failed logins; try again in 1 s","login":true} (HTTP 429)
```
A good login ends the waiting for that user. Behind a reverse proxy every request comes from `127.0.0.1`, so the waiting is shared by everyone who tries the same user name.
`kdb serve --data-dir` also writes every login, failed login and lockout to the audit log (`SHOW AUDIT`, chapter 21). Every login gets a new token: a login request that still carries an old token ends that old login. For people, turn on two-factor login (`ALTER USER admin ENABLE TOTP`, chapter 21).
### How KDB keeps passwords
- A PBKDF2-HMAC-SHA-256 hash with 600,000 rounds and a random salt (the same as `password_hash`).
- A SCRAM-SHA-256 secret (salt, StoredKey, ServerKey, 4096 rounds, like PostgreSQL). The PostgreSQL protocol uses it, so the password never crosses the network.
- Login tokens are random (256 bits). KDB keeps only their SHA-256, and only in memory.
- Passwords and tokens are compared in constant time.
Because the SCRAM secret has only 4,096 rounds (the PostgreSQL default), use **long random passwords** for database users, for example 20 or more characters from a password manager. People never type them often: programs keep them in their settings.
---
## Part C: VPS checklist
A small Linux VPS (1-2 CPU, 1-2 GB memory) with your app and KDB on the same machine.
### 1. Run KDB as its own user
```sh
sudo useradd --system --home /srv/kdb --shell /usr/sbin/nologin kdb
sudo mkdir -p /srv/kdb/data /srv/kdb/backups
sudo ./install.sh --system # from the unpacked Linux package (chapter 1)
sudo chown -R kdb:kdb /srv/kdb
sudo chmod 700 /srv/kdb/data /srv/kdb/backups
```
Only the `kdb` user (and root) can read the data and the backups.
`install.sh --system` puts the program in `/usr/local/bin/kdb` and its folders `web/`, `help/` and `skill/` in `/usr/local/share/kdb`, where `kdb` finds them by itself. Copying only the program works too, but then the server has no web console and no help pages.
### 2. Create users before the first start
```sh
sudo -u kdb kdb user add admin --role admin --data-dir /srv/kdb/data
sudo -u kdb kdb user add shop_app --role none --data-dir /srv/kdb/data
```
Then give `shop_app` its rights in SQL (`GRANT write ON DATABASE shop TO shop_app`) from the console, or run the app's `CREATE TABLE` statements as `admin` first.
### 3. Keep KDB on 127.0.0.1
```sh
kdb serve --data-dir /srv/kdb/data --backups /srv/kdb/backups
```
The default `--bind 127.0.0.1` means only programs on the VPS can connect. Your app's backend runs on the same VPS, so it can. Do **not** use `--bind 0.0.0.0` unless you really need it. Without a token and without users KDB refuses it anyway:
```text
error: --bind 0.0.0.0 without --token and without users would let anyone who can reach this computer
read, change and delete every database. Do one of these first:
1. create an admin user: kdb user add --role admin
2. or start with a secret: --token
3. or keep --bind 127.0.0.1 and put a web server with HTTPS in front of KDB
See docs/tutorial/16-users-passwords-security.md
```
KDB has no HTTPS. Over plain HTTP, passwords and tokens can be read by anyone on the network path.
### 4. If you need the console from home: HTTPS in front
Use a web server that gets a free certificate by itself, for example Caddy. `/etc/caddy/Caddyfile`:
```text
kdb.example.com {
reverse_proxy 127.0.0.1:9900
}
```
Caddy sends `X-Forwarded-Proto: https`; KDB then marks its login cookie `Secure`. KDB still asks for the login. Even better: do not open the console to the internet at all, and use an SSH tunnel from home:
```sh
ssh -L 9900:127.0.0.1:9900 you@your-vps
```
Then open on your own PC.
### 5. Firewall
Open only SSH and the web ports. Keep 9900 (HTTP API) and 8832 (PostgreSQL) closed:
```sh
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
```
Also use SSH keys instead of SSH passwords, and keep the system updated (`sudo apt update && sudo apt upgrade`).
### 6. Start KDB with the system
`/etc/systemd/system/kdb.service`:
```ini
[Unit]
Description=KDB database
After=network.target
[Service]
User=kdb
ExecStart=/usr/local/bin/kdb serve --data-dir /srv/kdb/data --backups /srv/kdb/backups
Restart=on-failure
KillSignal=SIGINT
TimeoutStopSec=60
[Install]
WantedBy=multi-user.target
```
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now kdb
journalctl -u kdb -f # the server's output, with "login failed" lines
```
For more protection in the same `ExecStart` line (statement timeouts, scheduled backups, `--safe-updates`), see [chapter 21](21-protection.md).
### 7. Backups, and a copy somewhere else
The easiest way: let the server do it, with a checked copy on a second disk ([chapter 21, Part C](21-protection.md#part-c-backups-on-a-schedule)):
```sh
kdb serve --data-dir /srv/kdb/data --backups /srv/kdb/backups --backup-schedule "daily 10:15" --backup-copy /mnt/backup/kdb
```
Or make a verified backup every night through the running server from a script (as a user with `admin` on that database):
```sh
curl -s -u admin:'the admin password' -X POST "http://127.0.0.1:9900/api/backup?db=shop"
```
Put that in a small script that reads the password from a file only root can read, and run it from `cron`. Then **copy the backups to another place** (another server, cloud storage): a backup on the same VPS is lost with the VPS. Test a restore now and then (chapter 9). Backups hold the password hashes of your app's users and the `_system` database: keep them as private as the data.
### 8. Short checklist
| Check | Done |
|---|---|
| KDB runs as its own user; data and backups are `chmod 700` | |
| `--bind 127.0.0.1` (the default); ports 9900 and 8832 closed in the firewall | |
| an `admin` user with a long random password; apps have their own users with the smallest role | |
| HTTPS (Caddy or nginx) or an SSH tunnel for the console | |
| app passwords stored with `password_hash`, checked with `password_verify` | |
| session tokens are random (`random_token`) and stored as `sha256` with an expiry | |
| nightly backup, copied off the VPS, restore tested | |
| `journalctl -u kdb` checked for `login failed` now and then (or `SHOW AUDIT WHERE event = 'login_failed'`) | |
| the protections of [chapter 21](21-protection.md#vps-checklist): timeouts, audit, scheduled backups, two-factor login for admins | |
---
## Limits
- Logins (sessions) are kept in memory: a restart logs everyone out.
- No HTTPS inside KDB; use a reverse proxy.
- Rights are per database, and per table with `GRANT ... ON TABLE` (chapter 21). No column or row rights.
- `kdb shell` and `kdb sql` do not ask for a login. Protect the data folder with file permissions.
- Old KDB versions do not know users. Never start an older `kdb` on a data folder that has users: it would serve it **without** a login.
---
Next: [Known limitations](known-limitations.md)
---
# 17. Indexes and search
- Category: Tutorial
## What you will learn
- Why a query can be slow, and how an index makes it fast.
- `CREATE INDEX`, `CREATE UNIQUE INDEX`, `DROP INDEX`, `SHOW INDEXES`.
- A forum: pages of a thread, replies, and fast "next page" links.
- News: full-text search in English, Nepali or any other language with `MATCH`, `search_rank` and `highlight`.
- How to see if a query uses an index (`EXPLAIN`).
- How fast it is, and the limits.
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 index | With index |
|---|---|
| Reads every row | Reads only the rows it needs |
| 1 million posts: tens of milliseconds, more with long text | 1 million posts: about 0.05 ms |
| Writes are a little faster | Every 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:
- When the database opens, `kdb serve` **builds every index at once**, on a background thread. The first query then finds it ready. Building takes about 0.2 seconds per million rows (a full-text index: about 2 seconds for 200,000 news articles). Queries and writes go on while it builds.
- A query that needs an index before the build is done builds it itself, as before. `kdb sql` and `kdb shell` do not build at open: there the **first query that needs an index builds it**. `kdb serve --warm-indexes off` (or `KDB_WARM_INDEXES=off`) makes the server do the same.
- After that, every change (`INSERT`, `UPDATE`, `DELETE`, `InsertRows`, line protocol, `ROLLBACK`) also changes the index.
- After a crash or a power cut, the index is built again from the rows. It can never be old or broken. `kdb check`, `kdb repair` and backups have nothing extra to do.
- Only the **definition** (`CREATE INDEX ...`) is saved, with the table.
- A slow query that a page asks again and again (a dashboard, a front page) can be **pinned**: the server keeps its result ready in the result cache and makes it again after a write. See the help topic **Pinned queries** (`/api/pins`).
## Step 1: the forum tables
01-forum-schema.sql:
```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);
```
```text
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)
```
- A reply has the `id` of the post it answers in `parent_id`. A post that starts a discussion has `parent_id` NULL.
- `posts_thread_time` has **three columns**. The order matters: first `thread_id`, then `created_at`, then `id`. It helps `WHERE thread_id = ...`, and also `WHERE thread_id = ... ORDER BY created_at`. It does **not** help `WHERE created_at > ...` alone, because `created_at` is not the first column.
- You can create an index on a table that already has rows. KDB builds it at once.
The syntax:
```sql
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:
```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;
```
```text
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:
```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;
```
```text
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)
```
- `parent_id IS NULL` can also use an index.
- Aggregates (`count`, `sum`, `GROUP BY`) read only the rows the index finds.
## Step 4: does my query use an index?
Put `EXPLAIN` before the query. 05-explain.sql (CSV output, so the lines are not cut):
```sql
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?';
```
```text
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 ` 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 query | Uses the index |
|---|---|
| `a = 5`, `a IN (1, 2, 3)`, `a IS NULL` | yes |
| `a = 5 AND b = 7` | yes, 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 20` | yes, and it stops after 20 rows |
| `a = 5 AND (b, c) < (x, y) ORDER BY b DESC, c DESC LIMIT 20` | yes: keyset pagination |
| `b = 7` alone | no: `b` is not the first column |
| `a = 5 OR b = 7` | no: `OR` |
| `lower(a) = 'x'`, `a + 1 = 6` | only 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:
- `UPDATE ... WHERE` and `DELETE ... WHERE` use indexes too.
- In a `JOIN`, only the first table (after `FROM`) uses an index.
- When an index would find more than a quarter of the table (for example `status = 'active'` on most rows), reading the whole table is faster, and KDB does that.
- The result is always the same, with or without an index. Only the speed changes.
## 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:
```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');
```
```text
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]
```
- The error names the index and the value. Your app can look for the word `unique` in the message and show "this email is already used".
- The check covers every way of writing: `INSERT`, `INSERT OR REPLACE`, `UPDATE`, `InsertRows`, transactions. Nothing is written when the check fails.
- Two rows may swap their values in one `UPDATE`.
- `NULL` values never clash: many rows may have a NULL email.
- `CREATE UNIQUE INDEX` on a table that already has two equal values fails, and names the value. Fix the rows first.
- A `UNIQUE` index can have several columns: `CREATE UNIQUE INDEX votes_one_per_user ON votes (user_id, post_id)` (chapter 18 uses this).
## Step 6: news and full-text search
`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:
```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.');
```
```text
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
| Rule | Example |
|---|---|
| Letters and digits of any language make words | `सिटी बैंकको नाफा` is 3 words |
| English letters: case does not matter | `Bank` = `bank` = `BANK` |
| Punctuation separates words, also `।` and `॥` | `छ।` is the word `छ` |
| A number keeps its dot; a comma inside a number is dropped | `12.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 stemming | `bank` does not find `banks`; `बैंक` does not find `बैंकले` (use `बैंक*`) |
### Searching
08-search.sql:
```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;
```
```text
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 write | It finds rows that |
|---|---|
| `city bank` | have 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 -merger` | have `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 `` and ` `, for a search result page.
09-highlight.sql:
```sql
SELECT id, highlight(title, 'bank') AS title FROM news WHERE MATCH(title, body, 'bank') ORDER BY id;
```
```text
id,title
1,City Bank profit rises 18%
4,"Market index closes at 2,840"
5,Coastal bank 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:
```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');
```
```text
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:
```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;
```
```text
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:
```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;
```
```text
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)
```
- `built` is `true` when the index is in memory now; `rows` and `memory_bytes` show its size.
- `building` is `true` while the server's background thread builds it right after the database opened. `kdb sql` never builds at open, so here it is always `false`.
- `SHOW CREATE TABLE` gives the table and then one `CREATE INDEX` line per index. Run them in this order to make the same table again.
13-drop-index.sql:
```sql
DROP INDEX posts_author;
DROP INDEX IF EXISTS posts_author;
ALTER TABLE posts DROP COLUMN parent_id;
```
```text
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]
```
- A column that an index uses cannot be dropped. `RENAME COLUMN` and `RENAME TABLE` keep the index working.
- `DROP TABLE` also drops the table's indexes.
- `CREATE INDEX` and `DROP INDEX` cannot run inside `BEGIN ... COMMIT`.
## 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.
| What | Rows | Time |
|---|---|---|
| `CREATE INDEX posts_thread ON posts (thread_id, created_at, id)` | 1,000,000 posts | 170 ms |
| Build of that index when the database opens (`kdb sql`: by the first query) | 1,000,000 posts | 150-320 ms, once |
| `SELECT * FROM posts WHERE author = 'user123'` (10 rows) | 1,000,000 posts | 0.03-0.04 ms |
| Forum page: `WHERE thread_id = x ORDER BY created_at DESC LIMIT 20` | 1,000,000 posts | 0.05-0.06 ms |
| Keyset page: `... AND (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20` | 1,000,000 posts | 0.06-0.07 ms |
| `INSERT` of one post (two indexes kept up to date, log saved to disk) | 1,000,000 posts | 0.3-1.8 ms (mostly the disk) |
| `CREATE FULLTEXT INDEX news_text ON news (title, body)` | 200,000 articles, 216 MB of text | 1.0-1.1 s |
| Build of that index when the database opens (`kdb sql`: by the first search) | 200,000 articles | 1.6 s, once |
| Top 20 by `search_rank` for common words | 200,000 articles (23,000 to 199,000 matches) | 6-15 ms |
| `UPDATE` of one article (full-text index kept up to date) | 200,000 articles | 0.4-2 ms |
| Memory: `posts_thread` / `posts_author` / `news_text` | | 50 MB / 35 MB / 102 MB |
Run it yourself: `.\build\bin\kdb_bench_indexes.exe 1000000 200000`.
## Tips
- An index on `(a, b)` also serves queries on `a` alone. You do not need a second index on `(a)`.
- Put the column you compare with `=` first, and the column you sort or compare with `<` / `>` next.
- For "latest N rows of X" add the time column after X: `(symbol, published_at)`, `(thread_id, created_at, id)`.
- Time-series tables already sort by `SORT BY`. Indexes help them for other columns, like `broker` in a trade log table.
- Every index costs memory (about the size of its column values plus 20-30 bytes per row) and makes writes a little slower. Big line-protocol imports into a table with indexes are slower; create the indexes after a big first import.
- The limits are listed in [known limitations](known-limitations.md).
## What you learned
- An index is a sorted list of columns that points to rows. It lives in memory. `kdb serve` builds it from the rows right after the database opens; `kdb sql` builds it when first needed.
- `CREATE INDEX` makes `=`, `IN`, ranges and `ORDER BY ... LIMIT` fast. `EXPLAIN` shows `use index`.
- Keyset pagination with a row comparison `(created_at, id) < (?, ?)` keeps every page fast.
- `CREATE UNIQUE INDEX` refuses duplicates.
- `CREATE FULLTEXT INDEX` with `MATCH`, `search_rank` and `highlight` searches text in English, Nepali and other languages.
---
# 18. Content data: companies, reports, news and a forum
- Category: Tutorial
## What you will learn
- How to store company data, quarterly numbers, financial reports, news and forum posts in KDB.
- The `JSON` type: store flexible data, read one value, change one value.
- The `BYTES` type: store hashes and small files.
- Text functions that work with text in any language (the examples use Nepali).
- How triggers keep forum counters (posts, votes) correct.
- Limits, and why big files should stay outside the database.
All examples are in examples/18-content-data/. The output under each example is real. The numbers are made up.
## The plan
A trading app needs more than prices. It also needs:
| Data | Table | Special types |
|---|---|---|
| Company details | `companies` | `JSON` for details that differ between companies |
| Numbers per quarter | `fundamentals` | `DECIMAL` for exact money, `JSON` for extra numbers |
| Financial reports | `reports` | `JSON` for the statements, `BYTES` for a file hash |
| News | `news` | text in any language, `JSON` tags |
| Forum | `forum_categories`, `forum_threads`, `forum_posts`, `forum_votes` | replies with `REFERENCES`, counters with triggers |
These are **relational** tables (chapter 3): each row has an id, and rows are changed often. Prices and candles stay in time-series tables.
## Step 1: the company tables
01-schema.sql:
```sql
CREATE TABLE companies (
symbol STRING PRIMARY KEY,
name STRING NOT NULL,
name_ne STRING,
sector STRING NOT NULL,
listed_shares LONG,
paid_up_capital DECIMAL(2),
listed_on DATE,
profile JSON DEFAULT '{}'
);
CREATE TABLE fundamentals (
id LONG PRIMARY KEY AUTO,
symbol STRING NOT NULL REFERENCES companies(symbol),
fiscal_year STRING NOT NULL,
quarter INT NOT NULL CHECK (quarter BETWEEN 1 AND 4),
period_end DATE NOT NULL,
eps DECIMAL(2),
book_value DECIMAL(2),
net_profit DECIMAL(2),
extra JSON DEFAULT '{}'
);
CREATE UNIQUE INDEX fundamentals_one_per_quarter ON fundamentals (symbol, fiscal_year, quarter);
CREATE TABLE reports (
id LONG PRIMARY KEY AUTO,
symbol STRING NOT NULL REFERENCES companies(symbol),
fiscal_year STRING NOT NULL,
kind STRING NOT NULL,
published_at TIMESTAMP NOT NULL,
statements JSON NOT NULL,
pdf_url STRING,
pdf_sha256 BYTES
);
CREATE TABLE news (
id LONG PRIMARY KEY AUTO,
symbol STRING REFERENCES companies(symbol),
published_at TIMESTAMP NOT NULL,
lang STRING NOT NULL DEFAULT 'en',
title STRING NOT NULL,
body STRING NOT NULL,
source_url STRING,
tags JSON DEFAULT '[]'
);
```
```text
table 'companies' created
table 'fundamentals' created
index 'fundamentals_one_per_quarter' created on table 'fundamentals' (0 rows)
table 'reports' created
table 'news' created
```
Why these types:
- **Money is `DECIMAL(2)`**, never `DOUBLE`. `DECIMAL` is exact (chapter 3).
- The fiscal year is text like `'2082/83'`, because it is a year of a local calendar (here the Bikram Sambat year used in Nepal). `period_end` is the normal (AD) date, so you can sort and filter by time.
- `CREATE UNIQUE INDEX` makes sure there is only one row per company, year and quarter. [Chapter 17](17-indexes-and-search.md) explains indexes.
- `CHECK (quarter BETWEEN 1 AND 4)` refuses a wrong quarter (chapter 15).
## Step 2: JSON
Some data is different for every company. A bank has branches and NPL (bad loans). A hydropower company has megawatts and rivers. Do not make 50 columns that are mostly empty. Put such data in a `JSON` column.
02-companies.sql:
```sql
INSERT INTO companies VALUES
('CITY', 'City Bank Limited', 'सिटी बैंक लिमिटेड', 'Banking', 270578640, 27057864000.00, '1985-07-12',
'{"website": "https://www.citybank.com", "head_office": "Kathmandu", "ceo": "A. Sharma", "branches": 270}'),
...
('VOLT', 'Volt River Hydropower Limited', 'भोल्ट रिभर जलविद्युत लिमिटेड', 'Energy', 105900000, 10590000000.00, '2019-01-21',
'{"website": "https://www.voltriverhydro.com", "capacity_mw": 456, "rivers": ["Volt River"]}');
SELECT symbol, name_ne, length(name_ne) AS letters, octet_length(name_ne) AS bytes, sector
FROM companies ORDER BY symbol;
SELECT symbol,
json_value(profile, '$.head_office') AS office,
profile ->> 'website' AS website,
json_value(profile, '$.capacity_mw') AS mw,
profile -> 'rivers' ->> 0 AS river
FROM companies ORDER BY symbol;
```
```text
3 row(s) inserted
symbol | name_ne | letters | bytes | sector
-------+------------------------------+---------+-------+--------
CITY | सिटी बैंक लिमिटेड | 17 | 47 | Banking
COAST | कोस्टल बैंक लिमिटेड | 19 | 53 | Banking
VOLT | भोल्ट रिभर जलविद्युत लिमिटेड | 28 | 78 | Energy
(3 rows, 0.1 ms)
symbol | office | website | mw | river
-------+-----------+--------------------------------+------+-----------
CITY | Kathmandu | https://www.citybank.com | NULL | NULL
COAST | Kathmandu | https://www.coastalbank.com | NULL | NULL
VOLT | NULL | https://www.voltriverhydro.com | 456 | Volt River
(3 rows, 0.1 ms)
```
What you see:
- `length()` counts **letters** (17). `octet_length()` counts **bytes** (47). A Nepali letter takes 3 bytes.
- A **path** starts with `$`. `$.head_office` is the key `head_office`. `$.rivers[0]` is the first element of the array `rivers`.
- A key that is not there gives `NULL`. It is not an error.
### How to read JSON
| You write | You get | Type of the result |
|---|---|---|
| `json_extract(doc, '$.a.b')` | the part, as JSON | `JSON` |
| `doc -> 'a' -> 'b'` | the same, shorter | `JSON` |
| `doc -> 0` | array element 0 (`-> -1` is the last one) | `JSON` |
| `doc ->> 'a'` | the part as text | `STRING` |
| `json_value(doc, '$.a')` | text, a whole number (`LONG`), a decimal number (`DOUBLE`) or `true`/`false` | depends on the value |
| `json_value_text` / `json_value_double` / `json_value_long` / `json_value_bool` | always this type (NULL if it does not fit) | `STRING` / `DOUBLE` / `LONG` / `BOOL` |
| `json_type(doc, '$.a')` | `object`, `array`, `string`, `number`, `boolean` or `null` | `STRING` |
| `json_array_length(doc, '$.a')` | number of elements | `LONG` |
| `json_keys(doc)` | the keys, as a JSON array | `JSON` |
Paths: `$.a.b`, `$.list[0]`, `$.list[-1]` (last), `$.list[last]`, `$."key with spaces"`, `$['key']`.
## Step 3: numbers per quarter
03-fundamentals.sql:
```sql
INSERT INTO fundamentals (symbol, fiscal_year, quarter, period_end, eps, book_value, net_profit, extra) VALUES
('CITY', '2082/83', 2, '2026-01-13', 10.85, 181.20, 1467500000.00, '{"npl_pct": 2.91, "car_pct": 12.8, "deposits": 431200000000}'),
('CITY', '2082/83', 3, '2026-04-13', 11.40, 184.05, 2310200000.00, '{"npl_pct": 2.75, "car_pct": 13.1, "deposits": 442900000000}'),
('CITY', '2082/83', 4, '2026-07-16', 12.10, 187.60, 3270100000.00, '{"npl_pct": 2.60, "car_pct": 13.4, "deposits": 455000000000, "audited": false}'),
('COAST', '2082/83', 4, '2026-07-16', 6.35, 158.90, 1011000000.00, '{"npl_pct": 4.10, "car_pct": 12.2}'),
('VOLT', '2082/83', 4, '2026-07-16', 3.20, 102.40, 338900000.00, '{"energy_gwh": 2105.4, "plant_factor_pct": 52.7}');
SELECT fiscal_year, quarter, period_end, eps, book_value FROM fundamentals
WHERE symbol = 'CITY' ORDER BY period_end;
SELECT symbol, quarter,
json_value(extra, '$.npl_pct') AS npl,
CAST(json_value_text(extra, '$.car_pct') AS DECIMAL(2)) AS car,
json_type(extra, '$.audited') AS audited_type
FROM fundamentals WHERE fiscal_year = '2082/83' AND quarter = 4 ORDER BY symbol;
SELECT symbol, extra ->> 'npl_pct' AS npl FROM fundamentals
WHERE quarter = 4 AND json_value_double(extra, '$.npl_pct') > 3;
UPDATE fundamentals SET extra = json_set(extra, '$.audited', TRUE, '$.auditor', 'CA firm A')
WHERE symbol = 'CITY' AND quarter = 4;
SELECT json_value(extra, '$.audited') AS audited, extra ->> 'auditor' AS auditor, extra ->> 'npl_pct' AS npl
FROM fundamentals WHERE symbol = 'CITY' AND quarter = 4;
```
```text
5 row(s) inserted
fiscal_year | quarter | period_end | eps | book_value
------------+---------+------------+-------+-----------
2082/83 | 2 | 2026-01-13 | 10.85 | 181.20
2082/83 | 3 | 2026-04-13 | 11.40 | 184.05
2082/83 | 4 | 2026-07-16 | 12.10 | 187.60
(3 rows, 0.1 ms)
symbol | quarter | npl | car | audited_type
-------+---------+------+-------+-------------
CITY | 4 | 2.6 | 13.40 | boolean
COAST | 4 | 4.1 | 12.20 | NULL
VOLT | 4 | NULL | NULL | NULL
(3 rows, 0.1 ms)
symbol | npl
-------+-----
COAST | 4.10
(1 row, 0.1 ms)
1 row(s) updated
audited | auditor | npl
--------+-----------+-----
true | CA firm A | 2.60
(1 row, 0.1 ms)
```
Notes:
- `json_value` gives `2.6` as a `DOUBLE`. For **exact** money or percent, take the text and cast it: `CAST(json_value_text(extra, '$.car_pct') AS DECIMAL(2))`.
- `->>` keeps the number as it was written: `4.10`.
- `json_set(doc, path, value, path, value, ...)` changes or adds keys. The other keys stay. Use it in `UPDATE`.
### How to change JSON
| Function | What it does |
|---|---|
| `json_set(doc, path, value, ...)` | adds the key, or replaces its value |
| `json_insert(doc, path, value, ...)` | adds only; a key that is already there stays |
| `json_replace(doc, path, value, ...)` | replaces only; a missing key is not added |
| `json_remove(doc, path, ...)` | removes keys or array elements |
| `json_array_append(doc, path, value)` | adds a value at the end of an array |
| `json_object('k', v, ...)`, `json_array(v, ...)` | builds new JSON |
The value can be text (`'yes'` becomes `"yes"`), a number, `TRUE`/`FALSE`, `NULL` (becomes `null`), or JSON (`json('{"a": 1}')`, or another JSON column). If the parent of the path does not exist, nothing changes.
### Bad JSON is refused
04-bad-json.sql:
```sql
INSERT INTO fundamentals (symbol, fiscal_year, quarter, period_end, extra) VALUES
('COAST', '2082/83', 3, '2026-04-13', '{"npl_pct": 4.3}'),
('VOLT', '2082/83', 3, '2026-04-13', '{"energy_gwh": 1850.2,}');
```
```text
error: row 2: column 'extra' (JSON): not valid JSON: a JSON object cannot end with ',' at character 23 (near '}')
```
The message says the row, the column and the character. Nothing was saved (also not row 1).
How KDB keeps JSON:
- It checks the text is valid JSON (RFC 8259). Keys need double quotes. `true`, `false` and `null` are lower case.
- It removes spaces between the parts. `{ "a" : 1 }` is saved as `{"a":1}`.
- It keeps the order of the keys and the digits of numbers (`2.60` stays `2.60`).
- A key written twice: a lookup finds the last one.
- JSON `null` is a value. It is not SQL `NULL`: `profile IS NULL` is false for `'null'`.
## Step 4: reports, and BYTES
A report has statements (balance sheet, income statement). Put them in a `JSON` column. The **PDF file stays outside the database** (see [Big files](#big-files-keep-them-outside)). KDB keeps its address and its SHA-256 hash.
05-reports.sql:
```sql
INSERT INTO reports (symbol, fiscal_year, kind, published_at, statements, pdf_url, pdf_sha256) VALUES
('CITY', '2082/83', 'quarterly', '2026-08-01T10:15:00+05:45',
'{"income": {"interest_income": 9820.5, "operating_profit": 4410.2, "net_profit": 3270.1},
"balance_sheet": {"total_assets": 612300.8, "deposits": 455000.0, "loans": 402100.3},
"unit": "USD million"}',
'https://files.example.com/city/2082-83-q4.pdf',
X'9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08'),
('VOLT', '2082/83', 'quarterly', '2026-08-03T16:00:00+05:45',
'{"income": {"sales": 1520.4, "net_profit": 338.9}, "unit": "USD million"}',
'https://files.example.com/volt/2082-83-q4.pdf', NULL);
SELECT symbol,
json_value(statements, '$.income.net_profit') AS net_profit,
statements -> 'balance_sheet' ->> 'total_assets' AS assets,
json_keys(statements) AS parts,
length(pdf_sha256) AS hash_bytes,
left(hex(pdf_sha256), 16) AS hash_start
FROM reports ORDER BY symbol;
SELECT to_base64(X'00FF10') AS b64, from_base64('AP8Q') AS back, X'0102' || X'00' AS joined,
'\x6869'::BYTES AS from_text, octet_length('नेपाल'::BYTES) AS utf8_bytes;
```
```text
2 row(s) inserted
symbol | net_profit | assets | parts | hash_bytes | hash_start
-------+------------+----------+-----------------------------------+------------+-----------------
CITY | 3270.1 | 612300.8 | ["income","balance_sheet","unit"] | 32 | 9f86d081884c7d65
VOLT | 338.9 | NULL | ["income","unit"] | NULL | NULL
(2 rows, 0.2 ms)
b64 | back | joined | from_text | utf8_bytes
-----+----------+----------+-----------+-----------
AP8Q | \x00ff10 | \x010200 | \x6869 | 15
(1 row, 0.1 ms)
```
About `BYTES`:
- `BYTES` holds any bytes, also zero bytes. Good for hashes, keys, small images and signatures.
- Write bytes as `X'0A0B'` or as text `'\x0a0b'`. Other text is saved as its UTF-8 bytes.
- KDB **shows** bytes as `\x` and hex (`\x00ff10`), in the shell, in CSV and in the HTTP API. This is the same as PostgreSQL. `'\x...'` reads back as the same bytes.
- On `BYTES`, `length()` and `substring()` count bytes.
- Functions: `hex()`, `unhex()`, `to_base64()`, `from_base64()`, `encode(b, 'hex' | 'base64')`, `decode(t, 'hex' | 'base64')`, and `||` to join.
## Step 5: news and text search
06-news.sql:
```sql
INSERT INTO news (symbol, published_at, lang, title, body, source_url, tags) VALUES
('CITY', '2026-08-01T11:00:00+05:45', 'en', 'City Bank profit rises 18%',
'City Bank reported a net profit of 3.27 billion dollars for Q4 of 2082/83.', 'https://news.example.com/1', '["results", "banking"]'),
('CITY', '2026-08-02T09:30:00+05:45', 'ne', 'सिटी बैंकको नाफा १८ प्रतिशतले बढ्यो',
'सिटी बैंकले आर्थिक वर्ष २०८२/८३ को चौथो त्रैमासिकमा ३ अर्ब २७ करोड नाफा कमाएको छ।', 'https://news.example.com/2', '["results"]'),
('VOLT', '2026-08-20T14:00:00+05:45', 'en', 'Volt River sells record energy',
'The plant produced 2105 GWh this year. Contact: info@voltriverhydro.com', NULL, '["hydro", "energy"]'),
(NULL, '2026-09-01T08:00:00+05:45', 'en', 'Market index closes at 2,840',
'The market gained 1.2% today. Banking stocks led the gains.', NULL, '[]');
SELECT id, lang, left(title, 30) AS preview, length(body) AS letters FROM news ORDER BY published_at;
SELECT id, title FROM news WHERE title ILIKE '%profit%' OR body ILIKE '%profit%' ORDER BY id;
SELECT id, title FROM news WHERE body LIKE '%नाफा%';
SELECT id, regexp_substr(body, '[\w.]+@[\w.]+\w') AS email FROM news WHERE regexp_like(body, '\w+@\w+');
SELECT to_char(published_at, 'YYYY-MM', '+05:45') AS month, lang, count(*) AS items
FROM news ORDER BY month, lang;
SELECT id, json_array_length(tags) AS tag_count, tags ->> 0 AS first_tag FROM news ORDER BY id;
```
```text
4 row(s) inserted
id | lang | preview | letters
---+------+--------------------------------+--------
1 | en | City Bank profit rises 18% | 74
2 | ne | सिटी बैंकको नाफा १८ प्रतिशतले | 81
3 | en | Volt River sells record energy | 71
4 | en | Market index closes at 2,840 | 59
(4 rows, 0.1 ms)
id | title
---+---------------------------
1 | City Bank profit rises 18%
(1 row, 0.0 ms)
id | title
---+------------------------------------
2 | सिटी बैंकको नाफा १८ प्रतिशतले बढ्यो
(1 row, 0.0 ms)
id | email
---+------------------------
3 | info@voltriverhydro.com
(1 row, 0.1 ms)
month | lang | items
--------+------+------
2026-08 | en | 2
2026-08 | ne | 1
2026-09 | en | 1
(3 rows, 0.1 ms)
id | tag_count | first_tag
---+-----------+----------
1 | 2 | results
2 | 1 | results
3 | 2 | hydro
4 | 0 | NULL
(4 rows, 0.0 ms)
```
Notes:
- `left(title, 30)` cuts after 30 **letters**, never in the middle of a Nepali letter.
- `ILIKE` is `LIKE` that ignores upper and lower case of English letters. In `LIKE`, `%` is any text and `_` is one letter (also one Nepali letter). Write `\%` to find a real `%`.
- `LIKE '%word%'`, `ILIKE` and `regexp_like` read **every row**. That is fine for thousands of rows. For big tables, use **full-text search with an index**: [chapter 17](17-indexes-and-search.md).
- `to_char(time, format, zone)` makes text from a time. Use it to group reports by month, quarter (`Q`) or year in a local time, such as Nepal (`'+05:45'` or `'Asia/Kathmandu'`) or New York in summer (`'-04:00'`).
- Regular expressions support `\d`, `\w` (also Nepali letters), `\s`, `[...]`, `(...)`, `|`, `*`, `+`, `?`, `{n,m}`. KDB never backtracks, so a bad pattern cannot hang the server.
### Text functions
| Function | Example | Result |
|---|---|---|
| `length(s)`, `octet_length(s)` | `length('नेपाल')`, `octet_length('नेपाल')` | `5`, `15` |
| `substring(s, from, len)` | `substring('नेपाल', 2, 2)` | `ेप` |
| `left(s, n)`, `right(s, n)` | `right('CITY', 3)` | `ITY` |
| `upper(s)`, `lower(s)` | `upper('city नेपाल')` | `CITY नेपाल` |
| `trim(s [, chars])`, `ltrim`, `rtrim` | `rtrim('नेपाल।।', '।')` | `नेपाल` |
| `replace(s, from, to)` | `replace('a-b', '-', '/')` | `a/b` |
| `split_part(s, sep, n)` | `split_part('2082/83', '/', 1)` | `2082` |
| `strpos(s, part)`, `position(part IN s)` | `position('बैंक' IN 'नेपाल बैंक')` | `7` |
| `starts_with`, `ends_with` | `ends_with('CITY', 'TY')` | `true` |
| `lpad(s, n, fill)`, `rpad` | `lpad('7', 3, '0')` | `007` |
| `concat(a, b)`, `a \|\| b`, `concat_ws(sep, ...)` | `'CITY' \|\| '-' \|\| 2082` | `CITY-2082` |
| `regexp_like`, `regexp_substr`, `regexp_count`, `regexp_replace` | `regexp_replace('a1b2', '\d', '#', 'g')` | `a#b#` |
| `coalesce`, `nullif`, `greatest`, `least` | `coalesce(name_ne, name)` | the first that is not NULL |
`a || b` gives NULL when a or b is NULL. `concat(a, b)` skips NULL. The SQL reference (chapter 11) lists every function.
## Step 6: the forum
07-forum-schema.sql (the triggers are shortened here; the file has all of them):
```sql
CREATE TABLE users (
id INT PRIMARY KEY AUTO,
name STRING NOT NULL UNIQUE
);
CREATE TABLE forum_categories (
id INT PRIMARY KEY AUTO,
slug STRING NOT NULL UNIQUE,
title STRING NOT NULL,
thread_count INT NOT NULL DEFAULT 0
);
CREATE TABLE forum_threads (
id LONG PRIMARY KEY AUTO,
category_id INT NOT NULL REFERENCES forum_categories(id),
symbol STRING REFERENCES companies(symbol),
user_id INT NOT NULL REFERENCES users(id),
title STRING NOT NULL,
created TIMESTAMP NOT NULL,
post_count INT NOT NULL DEFAULT 0,
last_post_at TIMESTAMP
);
CREATE TABLE forum_posts (
id LONG PRIMARY KEY AUTO,
thread_id LONG NOT NULL REFERENCES forum_threads(id),
parent_id LONG REFERENCES forum_posts(id),
user_id INT NOT NULL REFERENCES users(id),
body STRING NOT NULL,
created TIMESTAMP NOT NULL,
score INT NOT NULL DEFAULT 0,
meta JSON DEFAULT '{}'
);
CREATE TABLE forum_votes (
id LONG PRIMARY KEY AUTO,
post_id LONG NOT NULL REFERENCES forum_posts(id),
user_id INT NOT NULL REFERENCES users(id),
value INT NOT NULL CHECK (value IN (1, -1))
);
CREATE UNIQUE INDEX forum_votes_one_per_user ON forum_votes (user_id, post_id);
CREATE TRIGGER count_posts AFTER INSERT ON forum_posts EXECUTE BEGIN
FOR r IN (SELECT thread_id, count(*) AS n, max(created) AS last FROM NEW GROUP BY thread_id) DO
UPDATE forum_threads SET post_count = post_count + r.n, last_post_at = greatest(last_post_at, r.last)
WHERE id = r.thread_id;
END FOR;
END;
CREATE TRIGGER score_new_votes AFTER INSERT ON forum_votes EXECUTE BEGIN
FOR r IN (SELECT post_id, sum(value) AS v FROM NEW GROUP BY post_id) DO
UPDATE forum_posts SET score = score + r.v WHERE id = r.post_id;
END FOR;
END;
CREATE TRIGGER score_changed_votes AFTER UPDATE ON forum_votes EXECUTE BEGIN
FOR r IN (SELECT post_id, sum(value) AS v FROM OLD GROUP BY post_id) DO
UPDATE forum_posts SET score = score - r.v WHERE id = r.post_id;
END FOR;
FOR r IN (SELECT post_id, sum(value) AS v FROM NEW GROUP BY post_id) DO
UPDATE forum_posts SET score = score + r.v WHERE id = r.post_id;
END FOR;
END;
```
The ideas:
- **Replies**: `parent_id REFERENCES forum_posts(id)`. A reply points to the post it answers. The first post of a thread has `parent_id` NULL.
- **One vote per user**: the unique index on `(user_id, post_id)`.
- **Counters**: `thread_count`, `post_count` and `score` are kept by triggers. A trigger runs in the same transaction as the change. So the counter is never wrong, also after a crash. Counting on every page view (`count(*)`) would be slower.
- Triggers run **once per statement**. They read all new rows from `NEW` and old rows from `OLD` (chapter 13). This is why they use `GROUP BY`.
08-forum-posts.sql:
```sql
INSERT INTO users (name) VALUES ('ram'), ('sita'), ('hari');
INSERT INTO forum_categories (slug, title) VALUES ('banking', 'Banking stocks'), ('hydro', 'Hydropower');
INSERT INTO forum_threads (category_id, symbol, user_id, title, created) VALUES
(1, 'CITY', 1, 'CITY Q4 results: good or bad?', '2026-08-01T12:00:00+05:45'),
(2, 'VOLT', 2, 'VOLT dividend this year?', '2026-08-21T09:00:00+05:45');
INSERT INTO forum_posts (thread_id, parent_id, user_id, body, created, meta) VALUES
(1, NULL, 1, 'Profit is up 18%. I think the price will go up.', '2026-08-01T12:00:00+05:45', '{"device": "android"}'),
(2, NULL, 2, 'Energy sales were a record. Dividend soon?', '2026-08-21T09:00:00+05:45', '{}');
INSERT INTO forum_posts (thread_id, parent_id, user_id, body, created) VALUES
(1, 1, 2, 'But NPL is still 2.6%. Wait for the next quarter.', '2026-08-01T12:30:00+05:45');
INSERT INTO forum_posts (thread_id, parent_id, user_id, body, created, meta) VALUES
(1, 3, 1, 'NPL went down from 2.91%. That is good news.', '2026-08-01T13:05:00+05:45', '{"edited": true}'),
(1, 1, 3, 'नाफा राम्रो छ, तर मूल्य पहिले नै बढिसकेको छ।', '2026-08-01T15:40:00+05:45', '{"lang": "ne"}');
INSERT INTO forum_votes (post_id, user_id, value) VALUES (1, 2, 1), (1, 3, 1), (3, 1, -1), (3, 3, 1), (5, 1, 1);
SELECT slug, thread_count FROM forum_categories ORDER BY id;
SELECT id, title, post_count, last_post_at FROM forum_threads ORDER BY id;
SELECT p.id, p.parent_id, u.name, p.score, left(p.body, 30) AS text
FROM forum_posts p JOIN users u ON u.id = p.user_id
WHERE p.thread_id = 1 ORDER BY p.created;
SELECT parent_id, count(*) AS answers FROM forum_posts WHERE parent_id IS NOT NULL ORDER BY parent_id;
SELECT id, score FROM forum_posts ORDER BY score DESC, id LIMIT 1;
```
```text
3 row(s) inserted
2 row(s) inserted
2 row(s) inserted
2 row(s) inserted
1 row(s) inserted
2 row(s) inserted
5 row(s) inserted
slug | thread_count
--------+-------------
banking | 1
hydro | 1
(2 rows, 0.0 ms)
id | title | post_count | last_post_at
---+-------------------------------+------------+-------------------------
1 | CITY Q4 results: good or bad? | 4 | 2026-08-01T09:55:00.000Z
2 | VOLT dividend this year? | 1 | 2026-08-21T03:15:00.000Z
(2 rows, 0.0 ms)
id | parent_id | name | score | text
---+-----------+------+-------+-------------------------------
1 | NULL | ram | 2 | Profit is up 18%. I think the
3 | 1 | sita | 0 | But NPL is still 2.6%. Wait fo
4 | 3 | ram | 0 | NPL went down from 2.91%. That
5 | 1 | hari | 1 | नाफा राम्रो छ, तर मूल्य पहिले
(4 rows, 0.1 ms)
parent_id | answers
----------+--------
1 | 2
3 | 1
(2 rows, 0.1 ms)
id | score
---+------
1 | 2
(1 row, 0.0 ms)
```
A reply must be its own `INSERT`: the post it answers must already be saved. In an app this is always true, because people answer posts they can see.
### A second vote is refused
09-vote-twice.sql:
```sql
INSERT INTO forum_votes (post_id, user_id, value) VALUES (1, 2, 1);
```
```text
error: duplicate value violates unique index 'forum_votes_one_per_user' on table 'forum_votes': a row with user_id='2', post_id='1' already exists
```
In the app, show "you already voted", or change the vote with `UPDATE`:
10-change-vote.sql:
```sql
UPDATE forum_votes SET value = -1 WHERE user_id = 3 AND post_id = 3;
DELETE FROM forum_votes WHERE user_id = 1 AND post_id = 5;
SELECT id, score FROM forum_posts ORDER BY id;
INSERT INTO forum_posts (thread_id, parent_id, user_id, body, created)
VALUES (2, 2, 3, 'Last year they paid 10% bonus shares.', '2026-08-22T10:00:00+05:45')
RETURNING id, thread_id;
SELECT id, post_count, last_post_at FROM forum_threads ORDER BY id;
SELECT id, regexp_substr(body, '\d+(\.\d+)?%') AS percent FROM forum_posts
WHERE regexp_like(body, '\d%') ORDER BY id;
```
```text
1 row(s) updated
1 row(s) deleted
id | score
---+------
1 | 2
2 | 0
3 | -2
4 | 0
5 | 0
(5 rows, 0.0 ms)
id | thread_id
---+----------
6 | 2
(1 row, 0.7 ms)
1 row(s) inserted
id | post_count | last_post_at
---+------------+-------------------------
1 | 4 | 2026-08-01T09:55:00.000Z
2 | 2 | 2026-08-22T04:15:00.000Z
(2 rows, 0.0 ms)
id | percent
---+--------
1 | 18%
3 | 2.6%
4 | 2.91%
6 | 10%
(4 rows, 0.1 ms)
```
- Post 3 had +1 and −1. Hari changed +1 to −1, so the score went from 0 to −2.
- `RETURNING id` gives the new post's id at once (chapter 15).
## Step 7: JSON over HTTP
The HTTP API (chapter 8) sends a `JSON` column as a **real JSON object**, not as a string. `BYTES` are sent as a string with `\x` and hex. CSV export (`/exp`) writes JSON as text.
11-http.ps1 (it starts a server on port 9948):
```powershell
curl.exe -s "$url/exec?compat=0" --data-binary "SELECT symbol, statements -> 'income' AS income, pdf_sha256 FROM reports WHERE symbol = 'CITY'"
$r = Invoke-RestMethod -Method Post "$url/exec" -Body "SELECT symbol, statements FROM reports ORDER BY symbol"
foreach ($row in $r.dataset) { "{0}: net profit {1} {2}" -f $row[0], $row[1].income.net_profit, $row[1].unit }
curl.exe -s -G "$url/exp" --data-urlencode "query=SELECT id, tags FROM news ORDER BY id"
```
```text
{"query":"SELECT symbol, statements -> 'income' AS income, pdf_sha256 FROM reports WHERE symbol = 'CITY'","results":[{"statement":"SELECT symbol, statements -> 'income' AS income, pdf_sha256 FROM reports WHERE symbol = 'CITY'","columns":[{"name":"symbol","type":"STRING"},{"name":"income","type":"JSON"},{"name":"pdf_sha256","type":"BYTES"}],"dataset":[["CITY",{"interest_income":9820.5,"operating_profit":4410.2,"net_profit":3270.1},"\\x9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"]],"count":1,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":32,"execute_us":289}}],"timings":{"parse_us":32,"execute_us":289}}
CITY: net profit 3270.1 USD million
VOLT: net profit 338.9 USD million
id,tags
1,"[""results"",""banking""]"
2,"[""results""]"
3,"[""hydro"",""energy""]"
4,[]
```
In JSON, a backslash is written twice, so `"\\x9f86..."` is the text `\x9f86...`.
Other ways in and out:
- `kdb sql market "..." --format json` also writes JSON columns as objects.
- The PostgreSQL protocol (chapter 14) sends `JSON` as type `json` and `BYTES` as type `bytea`.
- The line protocol (`POST /write`) can fill `JSON` and `BYTES` columns from text fields: `news_feed,symbol=CITY payload="{\"price\": 512.5}"`. Bad JSON is refused with the line number.
## Big files: keep them outside
A `STRING`, `JSON` or `BYTES` value can be **at most 16 MB**. A bigger value gets this error:
```text
column 'body' (STRING): the value is 17000000 bytes; the limit is 16 MB (16777216 bytes). Keep big files outside the database and store their path or URL
```
Values of 1 MB work well: through INSERT, UPDATE, SELECT, the log, checkpoints, restarts, `/exec` and the line protocol (tested).
For PDF files, images and videos, the best plan is:
1. Save the file in a folder or an object store (for example S3-compatible storage).
2. In KDB, store the path or URL (`pdf_url STRING`) and the hash (`pdf_sha256 BYTES`).
3. When you read the file, check the hash.
Why: every change in KDB is written to the log and later to table files, and backups copy everything. Big files make the log, backups and restarts slow. Small things (hashes, thumbnails of a few KB, signatures) are fine in `BYTES`.
Other limits:
| Limit | Value |
|---|---|
| One text, JSON or BYTES value | 16 MB |
| JSON nesting | 256 levels |
| HTTP request body | 64 MB |
| Regular expression | 4096 bytes; `{n,m}` up to 1000; very big jobs stop with an error |
## Summary
| Need | Use |
|---|---|
| Data that differs between rows | a `JSON` column; read with `->`, `->>`, `json_value` |
| Exact numbers from JSON | `CAST(json_value_text(doc, '$.x') AS DECIMAL(2))` |
| Change one key | `UPDATE ... SET doc = json_set(doc, '$.key', value)` |
| Hashes, small binary data | `BYTES`, written `X'0A0B'`, shown `\x0a0b` |
| Text in any language | normal `STRING`; `length`, `left`, `substring` count letters |
| Search in a few thousand rows | `ILIKE`, `LIKE`, `regexp_like` |
| Search in big tables | full-text index, [chapter 17](17-indexes-and-search.md) |
| Counters (posts, votes) | triggers |
| One vote per user | `CREATE UNIQUE INDEX ... (user_id, post_id)` |
| Big files | outside KDB; store the URL and a hash |
## Try it yourself
1. Add `"dividend_pct": 10` to the `profile` of VOLT.
2. List the companies whose profile has a key `branches`, with the number of branches.
3. Find forum posts written in Nepali (hint: `meta ->> 'lang'`, or `regexp_like(body, '[ऀ-ॿ]')`).
Answers
1. `UPDATE companies SET profile = json_set(profile, '$.dividend_pct', 10) WHERE symbol = 'VOLT';`
2. `SELECT symbol, json_value(profile, '$.branches') AS branches FROM companies WHERE json_type(profile, '$.branches') IS NOT NULL;`
3. `SELECT id, body FROM forum_posts WHERE regexp_like(body, '[ऀ-ॿ]');`
---
Next: [19. Analysis functions](19-analysis-functions.md)
---
# 19. Analysis functions
- Category: Tutorial
### What you will learn
- Statistics over many rows: correlation, regression, skewness, percentiles, mode and more.
- Window functions: ranks, `first_value`, `lag` with a default, and frames with `FOLLOWING` and `RANGE '5 days'`.
- Returns and risk: returns, drawdown, volatility, Sharpe, Sortino, Calmar, beta, alpha, value at risk.
- Technical indicators: WMA, DEMA, TEMA, KAMA, HMA, MACD, stochastic, Bollinger bands, ADX, Aroon, Parabolic SAR, Ichimoku, OBV, MFI, SuperTrend, Heikin-Ashi, linear regression and more.
- Math, finance and date functions: `log`, `gcd`, `width_bucket`, `round_tick`, `pmt`, `irr`, `xirr`, `extract`, `age`, fiscal years.
- Table functions and pivots: `generate_series`, `long_sequence`, `PIVOT`.
- A cookbook: top gainers per day, 20-day volatility, drawdown, MACD crossovers, a Bollinger squeeze, a correlation matrix and turnover by sector and month.
All examples are in examples/19-analysis-functions/. The output under the cookbook examples is real. The prices are made up.
Chapter 5 explains `SAMPLE BY`, `GROUP BY` and the first window functions (`sma`, `ema`, `rsi`, `atr`, `lag`). Read it first.
### How to read this chapter
Every function has a short section:
- **Syntax**: how to write it. `[x]` means x is optional.
- **Returns**: the type of the result.
- **NULLs**: what happens with NULL values and with too few rows.
Three kinds of functions are in this chapter:
| Kind | Used where | Example |
|---|---|---|
| **Scalar** | anywhere: `SELECT`, `WHERE`, `ORDER BY` | `round_tick(close)` |
| **Aggregate** | with `GROUP BY` or `SAMPLE BY`, alone, or with `OVER (...)` | `corr(a, b)` |
| **Window / series** | in the `SELECT` list; without `OVER` they run in time order | `macd(close) OVER (PARTITION BY symbol ORDER BY time)` |
Rules for window and series functions (the same as in chapter 5):
- Without `OVER`, all result rows are **one series** in time order. With many symbols, always write `OVER (PARTITION BY symbol ORDER BY time)`.
- `WHERE` runs first. An indicator only sees the rows that `WHERE` keeps. Compute on more history in a subquery, filter outside.
- With `GROUP BY` or `SAMPLE BY`, a window function works on the **groups**: its arguments must be aggregates or group keys, for example `sma(last(close), 2)` or `rank() OVER (ORDER BY sum(volume) DESC)`. A plain column that is not a group key (`sma(close, 2)`) gives `column 'close' is used by a window function in a query with aggregates, so it must be a key of the groups`.
- One window function cannot be inside another (`macd(sma(close, 3))`). Use one subquery per step.
- The new aggregates work only in the row engine. Queries that use them do not use the fast column engine, so they are slower on very big tables (see [Speed](#speed)).
### The sample candles
The cookbook uses daily candles for the five symbols of the sample data (chapter 1). The sample table `ochl_day` is short, so this chapter makes a longer, made-up price history with a view. The view itself uses `generate_series` from this chapter:
01-candles.sql:
```sql
CREATE VIEW candles AS
SELECT time, symbol, sector,
round(close * (1 + 0.006 * sin(n * 1.3 + k)), 1) AS open,
round(greatest(close, close * (1 + 0.006 * sin(n * 1.3 + k))) * (1.004 + 0.003 * abs(sin(n * 0.7 + k))), 1) AS high,
round(least(close, close * (1 + 0.006 * sin(n * 1.3 + k))) * (0.996 - 0.003 * abs(cos(n * 0.9 + k))), 1) AS low,
close, volume
FROM (
SELECT time, symbol, sector, n, k,
round(base * (1 + 0.08 * sin(n / 11.0 + k) + 0.04 * sin(n / 3.0 + 2 * k) + 0.0004 * n * (k - 2)), 1) AS close,
round(50000 + 30000 * abs(sin(n * 0.37 + k))) AS volume
FROM (
SELECT d AS time, s.symbol AS symbol, s.sector AS sector, datediff('d', '2026-01-01', d) AS n,
CASE s.symbol WHEN 'CITY' THEN 0 WHEN 'COAST' THEN 1 WHEN 'VOLT' THEN 2 WHEN 'TELCO' THEN 3 ELSE 4 END AS k,
CASE s.symbol WHEN 'CITY' THEN 500 WHEN 'COAST' THEN 380 WHEN 'VOLT' THEN 180 WHEN 'TELCO' THEN 900 ELSE 250 END AS base
FROM generate_series('2026-01-01'::TIMESTAMP, '2026-09-10'::TIMESTAMP, '1 day') AS g(d)
CROSS JOIN securities s
WHERE is_trading_day(d)
) a
) b;
```
Each symbol has 181 trading days:
```sql
SELECT symbol, count(*) AS days, min(time) AS first_day, max(time) AS last_day, min(close) AS low, max(close) AS high
FROM candles GROUP BY symbol ORDER BY symbol;
```
```text
view 'candles' created
symbol | days | first_day | last_day | low | high
-------+------+--------------------------+--------------------------+-------+-------
ACME | 181 | 2026-01-01T00:00:00.000Z | 2026-09-10T00:00:00.000Z | 223 | 327.1
CITY | 181 | 2026-01-01T00:00:00.000Z | 2026-09-10T00:00:00.000Z | 371.8 | 542.9
COAST | 181 | 2026-01-01T00:00:00.000Z | 2026-09-10T00:00:00.000Z | 299.1 | 419.4
TELCO | 181 | 2026-01-01T00:00:00.000Z | 2026-09-10T00:00:00.000Z | 805.8 | 1076.2
VOLT | 181 | 2026-01-01T00:00:00.000Z | 2026-09-10T00:00:00.000Z | 158.8 | 201.2
(5 rows, 5.3 ms)
```
---
## Part 1: Aggregates
Aggregates look at many rows and give one value. Use them alone, with `GROUP BY`, with `SAMPLE BY`, or with `OVER (...)` for a moving window (for example `corr(a, b) OVER (ORDER BY time ROWS 19 PRECEDING)`).
The general NULL rule: **rows with NULL in a used argument are skipped**. For two-value aggregates (like `corr(y, x)`) the whole pair is skipped. With no rows left, the result is NULL (`regr_count`, `count_if` and `approx_count_distinct` give 0).
### Correlation and regression
| Name | Syntax | Meaning | NULLs / too few rows |
|---|---|---|---|
| `corr` | `corr(y, x)` | Pearson correlation, -1 to 1 | NULL when y or x does not change |
| `covar_pop` | `covar_pop(y, x)` | population covariance (divide by n) | NULL with no pairs |
| `covar_samp` | `covar_samp(y, x)` | sample covariance (divide by n - 1) | NULL with fewer than 2 pairs |
| `regr_slope` | `regr_slope(y, x)` | slope of the best straight line y = a + b·x | NULL when x does not change |
| `regr_intercept` | `regr_intercept(y, x)` | a of that line | NULL when x does not change |
| `regr_r2` | `regr_r2(y, x)` | how well the line fits, 0 to 1 | NULL when x does not change; 1 when only y does not change |
| `regr_count` | `regr_count(y, x)` | number of pairs without NULL | 0 with no pairs |
| `regr_avgx`, `regr_avgy` | `regr_avgx(y, x)` | average of x (of y) over the pairs | NULL with no pairs |
| `regr_sxx`, `regr_syy`, `regr_sxy` | `regr_sxx(y, x)` | sum of squared differences from the average | NULL with no pairs |
| `beta` | `beta(asset_returns, market_returns)` | how much the asset moves with the market | NULL with fewer than 2 pairs |
| `alpha` | `alpha(asset_returns, market_returns [, risk_free])` | return not explained by beta, per period | NULL with fewer than 2 pairs |
The argument order is **y first, then x**, as in PostgreSQL.
#### corr
- **Syntax:** `corr(y, x)`
- **Description:** the Pearson correlation of two columns. 1 means they move together, -1 means they move opposite, 0 means no straight-line relation.
- **Arguments:** y, x: numbers.
- **Returns:** DOUBLE between -1 and 1.
- **NULLs:** pairs with a NULL are skipped. NULL when fewer than 2 pairs, or when one column never changes.
- **Example:** `SELECT corr(n.close, u.close) FROM candles n JOIN candles u ON n.time = u.time WHERE n.symbol = 'CITY' AND u.symbol = 'VOLT'`
#### covar_pop
- **Syntax:** `covar_pop(y, x)`
- **Description:** population covariance: the average of (y - average y) × (x - average x).
- **Returns:** DOUBLE. **NULLs:** pairs with a NULL are skipped; NULL with no pairs.
- **Example:** `SELECT covar_pop(volume, close) FROM candles WHERE symbol = 'COAST'`
#### covar_samp
- **Syntax:** `covar_samp(y, x)`
- **Description:** sample covariance: like `covar_pop`, but divided by n - 1.
- **Returns:** DOUBLE. **NULLs:** pairs with a NULL are skipped; NULL with fewer than 2 pairs.
- **Example:** `SELECT covar_samp(volume, close) FROM candles WHERE symbol = 'COAST'`
#### regr_slope
- **Syntax:** `regr_slope(y, x)`
- **Description:** the slope b of the least-squares line y = a + b·x.
- **Returns:** DOUBLE. **NULLs:** pairs with a NULL are skipped; NULL when x never changes.
- **Example:** trend of the close per day: `SELECT symbol, regr_slope(close, datediff('d', '2026-01-01', time)) FROM candles GROUP BY symbol`
#### regr_intercept
- **Syntax:** `regr_intercept(y, x)`
- **Description:** the a of the least-squares line y = a + b·x (the value of y where x = 0).
- **Returns:** DOUBLE. **NULLs:** as `regr_slope`.
- **Example:** `SELECT regr_intercept(close, volume) FROM candles WHERE symbol = 'TELCO'`
#### regr_r2
- **Syntax:** `regr_r2(y, x)`
- **Description:** R², how much of the change in y the line explains (0 = nothing, 1 = everything).
- **Returns:** DOUBLE from 0 to 1. **NULLs:** NULL when x never changes; 1 when y never changes but x does.
- **Example:** `SELECT regr_r2(close, volume) FROM candles WHERE symbol = 'TELCO'`
#### regr_count
- **Syntax:** `regr_count(y, x)`
- **Description:** the number of rows where both y and x are not NULL.
- **Returns:** LONG. **NULLs:** 0 when there are no such rows.
- **Example:** `SELECT regr_count(close, volume) FROM candles`
#### regr_avgx
- **Syntax:** `regr_avgx(y, x)`, `regr_avgy(y, x)`
- **Description:** the average of x (or y), using only rows where both are not NULL.
- **Returns:** DOUBLE. **NULLs:** NULL with no pairs.
- **Example:** `SELECT regr_avgx(close, volume), regr_avgy(close, volume) FROM candles`
#### regr_sxx
- **Syntax:** `regr_sxx(y, x)`, `regr_syy(y, x)`, `regr_sxy(y, x)`
- **Description:** Σ(x - avg x)², Σ(y - avg y)² and Σ(x - avg x)(y - avg y) over the pairs.
- **Returns:** DOUBLE. **NULLs:** NULL with no pairs.
- **Example:** `SELECT regr_sxy(close, volume) / regr_sxx(close, volume) FROM candles` (the same as `regr_slope`)
#### beta
- **Syntax:** `beta(asset_returns, market_returns)`
- **Description:** beta of an asset against a benchmark: covariance / variance of the benchmark. Beta 1.2 means the asset moved 1.2 % when the market moved 1 %.
- **Arguments:** two columns of returns (for example from `pct_return`, see Part 3). Returns, not prices.
- **Returns:** DOUBLE. **NULLs:** pairs with a NULL are skipped; NULL with fewer than 2 pairs or when the benchmark never changes.
- **Example:** see [Beta against a benchmark](#beta-against-a-benchmark).
#### alpha
- **Syntax:** `alpha(asset_returns, market_returns [, risk_free])`
- **Description:** Jensen's alpha per period: (average asset return - risk_free) - beta × (average market return - risk_free). risk_free is a return per period (default 0). Multiply by the periods in a year (for example 240 or 252 trading days) for a simple yearly number.
- **Returns:** DOUBLE. **NULLs:** as `beta`.
- **Example:** see [Beta against a benchmark](#beta-against-a-benchmark).
### Shape of the data
| Name | Syntax | Meaning | NULLs / too few rows |
|---|---|---|---|
| `skewness` (`skew`) | `skewness(x)` | sample skewness (like Excel `SKEW`, pandas `skew`) | NULL with fewer than 3 values or no change |
| `skewness_pop` | `skewness_pop(x)` | population skewness | NULL with no change |
| `kurtosis` (`kurt`) | `kurtosis(x)` | sample excess kurtosis (like Excel `KURT`, pandas `kurt`) | NULL with fewer than 4 values or no change |
| `kurtosis_pop` | `kurtosis_pop(x)` | population excess kurtosis | NULL with no change |
| `geomean` (`geometric_mean`) | `geomean(x)` | geometric mean | NULL if a value is 0 or less |
| `harmonic_mean` | `harmonic_mean(x)` | harmonic mean | NULL if a value is 0 or less |
| `mode` | `mode(x)` or `mode() WITHIN GROUP (ORDER BY x)` | most frequent value | a tie gives the smallest value |
| `percentile_cont` (`quantile_cont`) | `percentile_cont(x, p)` or `percentile_cont(p) WITHIN GROUP (ORDER BY x)` | value at fraction p, between values | p must be 0 to 1 |
| `percentile_disc` (`quantile_disc`) | `percentile_disc(x, p)` or `... WITHIN GROUP (ORDER BY x)` | first real value at or above fraction p | any type that can be sorted |
| `median_abs_deviation` (`mad`) | `mad(x)` | median of \|x - median\| | NULL with no values |
| `iqr` | `iqr(x)` | 75th minus 25th percentile | NULL with no values |
| `range` | `range(x)` | max - min (for times: milliseconds, for DATE: days) | NULL with no values |
| `entropy` | `entropy(x)` | Shannon entropy of the values, in bits | NULL with no values |
#### skewness
- **Syntax:** `skewness(x)` (also `skew(x)`)
- **Description:** sample skewness, adjusted for the number of values (Fisher-Pearson G1, the same as Excel `SKEW` and pandas `skew()`). Positive: a long tail of big values. Negative: a long tail of small values.
- **Returns:** DOUBLE. **NULLs:** NULL values are skipped. NULL with fewer than 3 values or when all values are equal.
- **Example:** `SELECT symbol, round(skewness(r), 3) FROM (SELECT symbol, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) x GROUP BY symbol`
#### skewness_pop
- **Syntax:** `skewness_pop(x)`
- **Description:** population skewness g1 = m3 / m2^1.5, without the adjustment.
- **Returns:** DOUBLE. **NULLs:** NULL when all values are equal.
- **Example:** `SELECT skewness_pop(close) FROM candles WHERE symbol = 'CITY'`
#### kurtosis
- **Syntax:** `kurtosis(x)` (also `kurt(x)`)
- **Description:** sample excess kurtosis (G2, the same as Excel `KURT` and pandas `kurt()`). 0 is like a normal distribution; bigger means more extreme values ("fat tails").
- **Returns:** DOUBLE. **NULLs:** NULL with fewer than 4 values or when all values are equal.
- **Example:** `SELECT kurtosis(r) FROM (SELECT pct_return(close) AS r FROM candles WHERE symbol = 'TELCO') x`
#### kurtosis_pop
- **Syntax:** `kurtosis_pop(x)`
- **Description:** population excess kurtosis g2 = m4 / m2² - 3.
- **Returns:** DOUBLE. **NULLs:** NULL when all values are equal.
- **Example:** `SELECT kurtosis_pop(close) FROM candles WHERE symbol = 'TELCO'`
#### geomean
- **Syntax:** `geomean(x)` (also `geometric_mean(x)`)
- **Description:** the n-th root of the product of n values. For growth rates use `geomean(1 + r) - 1`.
- **Returns:** DOUBLE. **NULLs:** NULL values are skipped. NULL when any value is 0 or less.
- **Example:** `SELECT geomean(1 + r) - 1 AS avg_daily_growth FROM (SELECT pct_return(close) AS r FROM candles WHERE symbol = 'VOLT') x`
#### harmonic_mean
- **Syntax:** `harmonic_mean(x)`
- **Description:** n / Σ(1/x). The right average for ratios like P/E.
- **Returns:** DOUBLE. **NULLs:** NULL values are skipped. NULL when any value is 0 or less.
- **Example:** `SELECT harmonic_mean(close) FROM candles WHERE symbol = 'COAST'`
#### mode
- **Syntax:** `mode(x)` or `mode() WITHIN GROUP (ORDER BY x)`
- **Description:** the value that appears most often. Works for any type (numbers, text, times).
- **Returns:** the type of x. **NULLs:** NULL values are skipped. A tie gives the smallest value.
- **Example:** `SELECT mode(round(close, 0)) FROM candles WHERE symbol = 'CITY'`
#### percentile_cont
- **Syntax:** `percentile_cont(x, p)` or `percentile_cont(p) WITHIN GROUP (ORDER BY x)`; also `quantile_cont`
- **Description:** the value at fraction p of the sorted values, with linear interpolation between two values (the same rule as `median` and `percentile`). `p = 0.5` is the median.
- **Arguments:** x: numbers. p: a constant from 0 to 1 (the older `percentile(x, p)` also accepts 90 for 0.9; this one does not).
- **Returns:** DOUBLE. **NULLs:** NULL values are skipped. A NULL p gives NULL. `WITHIN GROUP (ORDER BY x DESC)` is not supported: use 1 - p.
- **Example:** `SELECT percentile_cont(0.9) WITHIN GROUP (ORDER BY volume) FROM candles`
#### percentile_disc
- **Syntax:** `percentile_disc(x, p)` or `percentile_disc(p) WITHIN GROUP (ORDER BY x)`; also `quantile_disc`
- **Description:** the first sorted value whose position is at or above fraction p. It is always one of the real values, so it also works for text and times.
- **Returns:** the type of x. **NULLs:** NULL values are skipped.
- **Example:** `SELECT percentile_disc(0.5) WITHIN GROUP (ORDER BY time) FROM candles`
#### median_abs_deviation
- **Syntax:** `median_abs_deviation(x)` (also `mad(x)`)
- **Description:** the median of the distances from the median. A robust measure of spread (not changed much by one extreme value). Multiply by 1.4826 to compare with a standard deviation.
- **Returns:** DOUBLE. **NULLs:** NULL values are skipped.
- **Example:** `SELECT symbol, mad(close) FROM candles GROUP BY symbol`
#### iqr
- **Syntax:** `iqr(x)`
- **Description:** interquartile range: `percentile_cont(x, 0.75) - percentile_cont(x, 0.25)`.
- **Returns:** DOUBLE. **NULLs:** NULL values are skipped.
- **Example:** `SELECT iqr(volume) FROM candles`
#### range
- **Syntax:** `range(x)`
- **Description:** `max(x) - min(x)`. Whole numbers stay whole numbers. For TIMESTAMP values it gives milliseconds, for DATE values days.
- **Returns:** LONG, DECIMAL or DOUBLE. **NULLs:** NULL values are skipped.
- **Example:** daily price range: `SELECT time, symbol, range(close) FROM ochl_minutes SAMPLE BY 1d`
#### entropy
- **Syntax:** `entropy(x)`
- **Description:** Shannon entropy of the distribution of values, in bits: -Σ p·log2(p). 0 means every value is the same.
- **Returns:** DOUBLE. **NULLs:** NULL values are skipped.
- **Example:** `SELECT entropy(buyer) FROM trade_log WHERE symbol = 'CITY'`
### Counting, conditions and picking rows
| Name | Syntax | Meaning | NULLs |
|---|---|---|---|
| `count_if` | `count_if(condition)` | rows where the condition is true | 0 with no rows |
| `sum_if` | `sum_if(x, condition)` | sum of x where the condition is true | NULL when no row matches |
| `avg_if` | `avg_if(x, condition)` | average of x where the condition is true | NULL when no row matches |
| `bool_and` (`every`) | `bool_and(b)` | true if every value is true | NULL values skipped |
| `bool_or` | `bool_or(b)` | true if any value is true | NULL values skipped |
| `arg_min` (`argmin`, `min_by`) | `arg_min(x, y)` | x of the row with the smallest y | rows with NULL y skipped |
| `arg_max` (`argmax`, `max_by`) | `arg_max(x, y)` | x of the row with the biggest y | rows with NULL y skipped |
| `approx_count_distinct` | `approx_count_distinct(x)` | number of different values (exact in KDB) | 0 with no rows |
| `sum_of_squares` | `sum_of_squares(x)` | Σx² | NULL with no values |
| `product` | `product(x)` | x1 × x2 × ... | NULL with no values, or when too big |
| `weighted_avg` (`wavg`) | `weighted_avg(x, weight)` | Σ(x·weight) / Σweight | NULL when the weights add up to 0 |
| `twap` | `twap(price, time)` | time-weighted average price | NULL with no rows |
#### count_if
- **Syntax:** `count_if(condition)`
- **Description:** counts the rows where the condition is true. The same as `count(CASE WHEN condition THEN 1 END)`.
- **Returns:** LONG. **NULLs:** a NULL condition is not counted. 0 with no rows.
- **Example:** up days per symbol: `SELECT symbol, count_if(close > open) AS up_days, count(*) AS days FROM candles GROUP BY symbol`
#### sum_if
- **Syntax:** `sum_if(x, condition)`
- **Description:** the sum of x over the rows where the condition is true. Whole numbers stay whole numbers.
- **Returns:** like `sum`. **NULLs:** NULL x values and rows where the condition is false or NULL are skipped. NULL when nothing is added.
- **Example:** `SELECT symbol, sum_if(volume, close > open) AS up_volume FROM candles GROUP BY symbol`
#### avg_if
- **Syntax:** `avg_if(x, condition)`
- **Description:** the average of x over the rows where the condition is true.
- **Returns:** DOUBLE. **NULLs:** as `sum_if`.
- **Example:** `SELECT avg_if(close - open, close > open) FROM candles`
#### bool_and
- **Syntax:** `bool_and(b)` (also `every(b)`)
- **Description:** true when every value is true.
- **Returns:** BOOL. **NULLs:** NULL values are skipped; NULL with no values.
- **Example:** did the price go up every day this week? `SELECT symbol, bool_and(close > open) FROM candles WHERE time >= '2026-09-06' GROUP BY symbol`
#### bool_or
- **Syntax:** `bool_or(b)`
- **Description:** true when at least one value is true.
- **Returns:** BOOL. **NULLs:** as `bool_and`.
- **Example:** `SELECT symbol, bool_or(high > 1000) FROM candles GROUP BY symbol`
#### arg_min
- **Syntax:** `arg_min(x, y)` (also `argmin`, `min_by`)
- **Description:** the value of x in the row where y is smallest. On a tie, the earliest row (by time) wins.
- **Returns:** the type of x. **NULLs:** rows where y is NULL are skipped; x may be NULL.
- **Example:** the day of the lowest close: `SELECT symbol, arg_min(time, close) FROM candles GROUP BY symbol`
#### arg_max
- **Syntax:** `arg_max(x, y)` (also `argmax`, `max_by`)
- **Description:** the value of x in the row where y is biggest. On a tie, the earliest row wins.
- **Returns:** the type of x. **NULLs:** as `arg_min`.
- **Example:** `SELECT symbol, arg_max(time, volume) AS busiest_day FROM candles GROUP BY symbol`
#### approx_count_distinct
- **Syntax:** `approx_count_distinct(x)`
- **Description:** the number of different values. KDB counts exactly; the name is for programs written for other databases.
- **Returns:** LONG. **NULLs:** NULL values are not counted; 0 with no rows.
- **Example:** `SELECT approx_count_distinct(buyer) FROM trade_log`
#### sum_of_squares
- **Syntax:** `sum_of_squares(x)`
- **Description:** Σx² (not the differences from the average: for those use `regr_sxx(x, x)`).
- **Returns:** DOUBLE. **NULLs:** NULL values are skipped.
- **Example:** `SELECT sqrt(sum_of_squares(r) / count(r)) FROM (SELECT pct_return(close) AS r FROM candles WHERE symbol = 'CITY') x`
#### product
- **Syntax:** `product(x)`
- **Description:** all values multiplied. For a total return: `product(1 + r) - 1`.
- **Returns:** DOUBLE. **NULLs:** NULL values are skipped. NULL when the result is too big for a DOUBLE.
- **Example:** `SELECT product(1 + r) - 1 AS total_return FROM (SELECT pct_return(close) AS r FROM candles WHERE symbol = 'COAST') x`
#### weighted_avg
- **Syntax:** `weighted_avg(x, weight)` (also `wavg`)
- **Description:** Σ(x × weight) / Σweight. `vwap(price, volume)` is the same with the volume as weight.
- **Returns:** DOUBLE. **NULLs:** rows where x or weight is NULL are skipped. NULL when the weights add up to 0.
- **Example:** `SELECT weighted_avg(rate, quantity) FROM trade_log WHERE symbol = 'CITY'`
#### twap
- **Syntax:** `twap(price, time)`
- **Description:** time-weighted average price. Each price counts for the time until the next price. The last price has no time after it, so it does not count (unless it is the only price). When every price has the same time, the plain average.
- **Arguments:** price: numbers. time: TIMESTAMP, DATE or milliseconds.
- **Returns:** DOUBLE. **NULLs:** rows where price or time is NULL are skipped.
- **Example:** `SELECT symbol, twap(ltp, timestamp) FROM live_data GROUP BY symbol`
### Text and JSON lists
| Name | Syntax | Meaning | NULLs |
|---|---|---|---|
| `string_agg` | `string_agg(x, separator)` | values joined as text, in time order | NULL values skipped |
| `listagg` | `listagg(x [, separator])` | the same; separator default `,` | NULL values skipped |
| `array_agg` (`json_agg`) | `array_agg(x)` | a JSON array of the values, in time order | NULL values become `null` |
#### string_agg
- **Syntax:** `string_agg(x, separator)`; `string_agg(DISTINCT x, separator)` for different values only
- **Description:** joins the values as text. The rows are joined in time order (the table's time column), or in the order they were read for tables without one.
- **Returns:** STRING. **NULLs:** NULL values are skipped. NULL when there is nothing to join.
- **Example:** `SELECT sector, string_agg(symbol, ', ') FROM securities GROUP BY sector`
#### listagg
- **Syntax:** `listagg(x [, separator])`
- **Description:** the same as `string_agg`; the separator is `,` when not given (Oracle's name).
- **Returns:** STRING. **NULLs:** as `string_agg`.
- **Example:** `SELECT listagg(symbol) FROM securities`
#### array_agg
- **Syntax:** `array_agg(x)` (also `json_agg(x)`)
- **Description:** a JSON array of all values, in time order. Use the JSON functions of chapter 18 to read it.
- **Returns:** JSON. **NULLs:** NULL values become `null` in the array. NULL with no rows.
- **Example:** `SELECT symbol, array_agg(close) FROM candles WHERE time >= '2026-09-06' GROUP BY symbol`
### Risk aggregates
These work on a column of **returns** (for example `pct_return(close)`, Part 3), in time order. Use a subquery to make the returns first. `periods` is the number of periods in one year: about 240 or 252 trading days (it depends on the exchange), 52 for weeks, 12 for months.
| Name | Syntax | Meaning | NULLs / too few rows |
|---|---|---|---|
| `sharpe_ratio` | `sharpe_ratio(r [, risk_free [, periods]])` | (average r - risk_free) / standard deviation × √periods | NULL with fewer than 2 returns or no change |
| `sortino_ratio` | `sortino_ratio(r [, target [, periods]])` | like Sharpe, but only falls below target count as risk | NULL when no return is below target |
| `calmar_ratio` | `calmar_ratio(r, periods)` | yearly growth / largest drawdown | NULL when there is no drawdown |
| `information_ratio` | `information_ratio(r, benchmark_r [, periods])` | average extra return / its standard deviation × √periods | NULL with fewer than 2 pairs |
| `max_drawdown` | `max_drawdown(price)` | largest fall from a peak, as a negative fraction | NULL if a price is 0 or less |
| `value_at_risk` | `value_at_risk(r, confidence)` | loss that is not passed with this confidence (historical) | NULL with no returns |
| `expected_shortfall` | `expected_shortfall(r, confidence)` | average loss in the worst cases beyond value at risk | NULL with no returns |
#### sharpe_ratio
- **Syntax:** `sharpe_ratio(r [, risk_free [, periods]])`
- **Description:** (average of r - risk_free) / sample standard deviation of r × √periods. risk_free is a return per period (default 0). periods default 1 (no yearly scaling). The same definition as the Python package empyrical.
- **Returns:** DOUBLE. **NULLs:** NULL returns are skipped. NULL with fewer than 2 returns or when every return is the same.
- **Example:** `SELECT symbol, round(sharpe_ratio(r, 0, 240), 2) FROM (SELECT symbol, time, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) x GROUP BY symbol`
#### sortino_ratio
- **Syntax:** `sortino_ratio(r [, target [, periods]])`
- **Description:** (average of r - target) / downside deviation × √periods. The downside deviation is √(average of min(0, r - target)²) over all returns (as empyrical). target default 0, periods default 1.
- **Returns:** DOUBLE. **NULLs:** NULL returns are skipped. NULL when no return is below the target.
- **Example:** `SELECT sortino_ratio(r, 0, 240) FROM (SELECT time, pct_return(close) AS r FROM candles WHERE symbol = 'CITY') x`
#### calmar_ratio
- **Syntax:** `calmar_ratio(r, periods)`
- **Description:** yearly growth rate / |max drawdown|. Wealth starts at 1 and is multiplied by (1 + r) for each return in time order. Yearly growth = wealth^(periods / number of returns) - 1. The drawdown is measured on that wealth, from the start value 1.
- **Returns:** DOUBLE. **NULLs:** NULL returns are skipped. NULL when the wealth never falls, or falls to 0 or less.
- **Example:** `SELECT calmar_ratio(r, 240) FROM (SELECT time, pct_return(close) AS r FROM candles WHERE symbol = 'VOLT') x`
#### information_ratio
- **Syntax:** `information_ratio(r, benchmark_r [, periods])`
- **Description:** average of (r - benchmark_r) / sample standard deviation of (r - benchmark_r) × √periods.
- **Returns:** DOUBLE. **NULLs:** pairs with a NULL are skipped; NULL with fewer than 2 pairs.
- **Example:** see [Beta against a benchmark](#beta-against-a-benchmark).
#### max_drawdown
- **Syntax:** `max_drawdown(price)`
- **Description:** the largest fall from a previous peak, in time order, as a fraction: -0.25 means the price fell 25 % below its highest point. 0 when the price never fell. Works on **prices** (for returns use `calmar_ratio`, or `product` in a subquery).
- **Returns:** DOUBLE, 0 or less. **NULLs:** NULL prices are skipped. NULL when a price is 0 or less.
- **Example:** `SELECT symbol, round(max_drawdown(close) * 100, 1) AS max_dd_pct FROM candles GROUP BY symbol`
#### value_at_risk
- **Syntax:** `value_at_risk(r, confidence)`
- **Description:** historical value at risk: the loss (as a positive number) that the returns did not pass in `confidence` of the cases. It is `-percentile_cont(r, 1 - confidence)`. 0.03 with confidence 0.95 means: on 95 % of days the loss was 3 % or less.
- **Arguments:** confidence: more than 0 and less than 1, like 0.95 or 0.99.
- **Returns:** DOUBLE. **NULLs:** NULL returns are skipped.
- **Example:** `SELECT symbol, value_at_risk(r, 0.95) FROM (SELECT symbol, time, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) x GROUP BY symbol`
#### expected_shortfall
- **Syntax:** `expected_shortfall(r, confidence)`
- **Description:** conditional value at risk: the average of the returns at or below the value-at-risk level, as a positive loss.
- **Returns:** DOUBLE. **NULLs:** NULL returns are skipped.
- **Example:** `SELECT expected_shortfall(r, 0.95) FROM (SELECT time, pct_return(close) AS r FROM candles WHERE symbol = 'TELCO') x`
### Money over time
| Name | Syntax | Meaning | NULLs |
|---|---|---|---|
| `xnpv` | `xnpv(rate, amount, date)` | net present value of cash flows on dates (Excel `XNPV`) | rows with NULL skipped |
| `xirr` | `xirr(amount, date [, guess])` | yearly rate that makes XNPV 0 (Excel `XIRR`) | NULL without both signs |
#### xnpv
- **Syntax:** `xnpv(rate, amount, date)`
- **Description:** Σ amount / (1 + rate)^(days since the first date / 365), like Excel `XNPV`. The first date is the earliest date.
- **Returns:** DOUBLE. **NULLs:** rows with a NULL amount or date are skipped. NULL when rate is -1 or less.
- **Example:** `SELECT xnpv(0.09, amount, d) FROM cash_flows` (a table with an `amount` DOUBLE and a `d` DATE column)
#### xirr
- **Syntax:** `xirr(amount, date [, guess])`
- **Description:** the yearly rate r where `xnpv(r, amount, date)` is 0, like Excel `XIRR`. Money you pay is negative, money you get is positive. Useful for the real return of a stock portfolio with buys, dividends and sells on different dates.
- **Returns:** DOUBLE (0.12 = 12 % per year). **NULLs:** rows with a NULL are skipped. NULL when all amounts have the same sign or no rate is found.
- **Example:** `SELECT xirr(amount, d) FROM cash_flows` (for Excel's example -10000, 2750, 4250, 3250, 2750 the result is 0.3733625...)
---
## Part 2: Window functions
### OVER and frames
A window function gets `OVER (...)`:
```sql
fn(...) OVER ([PARTITION BY expr, ...] [ORDER BY expr [ASC|DESC], ...] [frame])
```
The **frame** says which rows of the partition each row looks at. Aggregates (`sum`, `avg`, `corr`, ...), `first_value`, `last_value`, `nth_value`, `zscore` and `minmax_scale` use the frame. Ranks, `lag`, `lead` and the indicators ignore it.
| Frame | Rows for each row |
|---|---|
| no frame, no `ORDER BY` | the whole partition |
| no frame, with `ORDER BY` | from the first row of the partition to this row (running) |
| `ROWS n PRECEDING` | this row and n rows before it |
| `ROWS BETWEEN n PRECEDING AND m FOLLOWING` | n rows before to m rows after |
| `ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING` | this row to the last row |
| `ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING` | the 5 rows before, without this row |
| `RANGE BETWEEN '5 days' PRECEDING AND CURRENT ROW` | rows whose time is at most 5 days before this row's time |
| `RANGE BETWEEN 10 PRECEDING AND 10 FOLLOWING` | rows whose ORDER BY value is within 10 of this row's value |
| `RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING` | this row, rows with the same ORDER BY value, and all after |
Rules:
- Bounds: `UNBOUNDED PRECEDING`, `n PRECEDING`, `CURRENT ROW`, `n FOLLOWING`, `UNBOUNDED FOLLOWING`. The end must not be before the start.
- `ROWS n PRECEDING` without `BETWEEN` means `ROWS BETWEEN n PRECEDING AND CURRENT ROW`. `RANGE ... PRECEDING` alone is the same for RANGE.
- `RANGE` with a distance needs exactly one `ORDER BY` value, a number or a time. For TIMESTAMP write text like `'5 days'`, `'2 hours'`, `'30 minutes'`, `'1 week'` (or `INTERVAL '5 days'`), or a number of milliseconds. For DATE a number is days. Months and years are not supported (they have no fixed length).
- With `RANGE`, `CURRENT ROW` includes the rows with the **same** ORDER BY value (peers). A row with a NULL ORDER BY value has the other NULL rows as its peers.
- A frame with no rows gives NULL (`count` gives 0).
- Frames that start at the first row, or end at the last row, are computed in one pass. Other `BETWEEN` frames add up every frame again: `ROWS BETWEEN 200 PRECEDING AND 200 FOLLOWING` on a million rows is slow.
Example: the average close of the week around each day, and the volume of the last 7 calendar days (there are no candles on Fridays and Saturdays, so 7 days are 5 rows):
```sql
SELECT time, close,
round(avg(close) OVER (ORDER BY time ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING), 2) AS centered_avg,
sum(volume) OVER (ORDER BY time RANGE BETWEEN '6 days' PRECEDING AND CURRENT ROW) AS volume_7d
FROM candles WHERE symbol = 'CITY' AND time >= '2026-08-25';
```
02-frames.sql:
```text
time | close | centered_avg | volume_7d
-------------------------+-------+--------------+----------
2026-08-25T00:00:00.000Z | 423.5 | 413.83 | 68026
2026-08-26T00:00:00.000Z | 413.6 | 407.23 | 126161
2026-08-27T00:00:00.000Z | 404.4 | 402.96 | 179019
2026-08-30T00:00:00.000Z | 387.4 | 395.5 | 257039
2026-08-31T00:00:00.000Z | 385.9 | 390.38 | 337039
2026-09-01T00:00:00.000Z | 386.2 | 387.62 | 346933
2026-09-02T00:00:00.000Z | 388 | 389.42 | 360859
2026-09-03T00:00:00.000Z | 390.6 | 391.38 | 371217
2026-09-06T00:00:00.000Z | 396.4 | 392.76 | 361444
2026-09-07T00:00:00.000Z | 395.7 | 392.86 | 357067
2026-09-08T00:00:00.000Z | 393.1 | 391.16 | 358678
2026-09-09T00:00:00.000Z | 388.5 | 389.85 | 366060
2026-09-10T00:00:00.000Z | 382.1 | 387.9 | 378213
(13 rows, 4.9 ms)
```
### Ranking and position
| Name | Syntax | Meaning | NULLs |
|---|---|---|---|
| `row_number`, `rank`, `dense_rank` | `rank() OVER (...)` | see chapter 5 | NULL ORDER BY values sort last |
| `percent_rank` | `percent_rank() OVER (...)` | (rank - 1) / (rows - 1), 0 to 1 | one row gives 0 |
| `cume_dist` | `cume_dist() OVER (...)` | share of rows at or before this row's value, 0 to 1 | |
| `ntile` | `ntile(n) OVER (...)` | bucket number 1..n of about equal size | |
| `first_value` | `first_value(x) OVER (...)` | x of the first row of the frame | may be NULL |
| `last_value` | `last_value(x) OVER (...)` | x of the last row of the frame | may be NULL |
| `nth_value` | `nth_value(x, n) OVER (...)` | x of the n-th row of the frame | NULL when the frame is shorter |
| `lag`, `lead` | `lag(x [, k [, default]])` | x of k rows before (after) | default when there is no such row |
#### percent_rank
- **Syntax:** `percent_rank() OVER ([PARTITION BY ...] ORDER BY ...)`
- **Description:** relative rank: (rank - 1) / (rows in the partition - 1). The first row is 0, the last 1. Equal values get the same number.
- **Returns:** DOUBLE. **NULLs:** NULL ORDER BY values sort last. A partition of one row gives 0.
- **Example:** `SELECT symbol, time, percent_rank() OVER (PARTITION BY time ORDER BY volume) FROM candles WHERE time = '2026-09-10'`
#### cume_dist
- **Syntax:** `cume_dist() OVER ([PARTITION BY ...] ORDER BY ...)`
- **Description:** cumulative distribution: (rows with a value before or equal to this row's value) / rows in the partition.
- **Returns:** DOUBLE, more than 0 up to 1. **NULLs:** NULL ORDER BY values sort last.
- **Example:** `SELECT time, close, cume_dist() OVER (ORDER BY close) FROM candles WHERE symbol = 'TELCO'`
#### ntile
- **Syntax:** `ntile(n) OVER ([PARTITION BY ...] ORDER BY ...)`
- **Description:** splits the rows into n buckets numbered 1 to n. When the rows do not divide evenly, the first buckets get one row more (as PostgreSQL).
- **Arguments:** n: a whole number, 1 or more.
- **Returns:** LONG.
- **Example:** quartile of today's volume: `SELECT symbol, ntile(4) OVER (ORDER BY volume DESC) FROM candles WHERE time = '2026-09-10'`
#### first_value
- **Syntax:** `first_value(x) OVER (...)`
- **Description:** the value of x in the first row of the frame. Unlike `first(x)`, NULL is not skipped.
- **Returns:** the type of x. **NULLs:** NULL when that row's x is NULL, or the frame is empty.
- **Example:** `SELECT time, close, close / first_value(close) OVER (PARTITION BY symbol ORDER BY time) - 1 AS since_start FROM candles`
#### last_value
- **Syntax:** `last_value(x) OVER (...)`
- **Description:** the value of x in the last row of the frame. With the default running frame that is the current row; use `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING` for the last row of the partition.
- **Returns:** the type of x. **NULLs:** NULL when that row's x is NULL, or the frame is empty.
- **Example:** `SELECT time, last_value(close) OVER (PARTITION BY symbol ORDER BY time ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) FROM candles`
#### nth_value
- **Syntax:** `nth_value(x, n) OVER (...)`
- **Description:** the value of x in the n-th row of the frame (1 = first).
- **Returns:** the type of x. **NULLs:** NULL when the frame has fewer than n rows.
- **Example:** `SELECT time, nth_value(close, 2) OVER (PARTITION BY symbol ORDER BY time) FROM candles`
#### lag
- **Syntax:** `lag(x [, k [, default]])`, `lead(x [, k [, default]])`
- **Description:** the value of x k rows before (lag) or after (lead) this row; k default 1. The third argument is new: the value to use when there is no such row (instead of NULL). It is evaluated for the current row.
- **Returns:** the type of x. **NULLs:** NULL (or the default) at the start (lag) or end (lead) of the partition.
- **Example:** `SELECT time, close - lag(close, 1, open) OVER (PARTITION BY symbol ORDER BY time) AS change FROM candles`
### Cumulative values
| Name | Syntax | Meaning | NULLs |
|---|---|---|---|
| `cumsum` | `cumsum(x)` | running total (chapter 5) | NULL values skipped |
| `cumprod` | `cumprod(x)` | running product | NULL values skipped; NULL before the first value |
| `cummax` | `cummax(x)` | highest value so far | NULL values skipped |
| `cummin` | `cummin(x)` | lowest value so far | NULL values skipped |
#### cumprod
- **Syntax:** `cumprod(x)` or `cumprod(x) OVER (PARTITION BY ... ORDER BY ...)`
- **Description:** the product of all values from the first row to this row (pandas `cumprod`). For growth of 1 dollar: `cumprod(1 + r)`.
- **Returns:** DOUBLE. **NULLs:** NULL values are skipped (the product stays). NULL before the first value.
- **Example:** `SELECT time, cumprod(1 + r) FROM (SELECT time, pct_return(close) AS r FROM candles WHERE symbol = 'CITY') x`
#### cummax
- **Syntax:** `cummax(x)` or `cummax(x) OVER (...)`
- **Description:** the highest value from the first row to this row (any type that can be compared).
- **Returns:** the type of x. **NULLs:** NULL values are skipped. NULL before the first value.
- **Example:** `SELECT time, close, cummax(high) OVER (PARTITION BY symbol ORDER BY time) AS highest_so_far FROM candles`
#### cummin
- **Syntax:** `cummin(x)` or `cummin(x) OVER (...)`
- **Description:** the lowest value from the first row to this row.
- **Returns:** the type of x. **NULLs:** as `cummax`.
- **Example:** `SELECT time, cummin(low) OVER (PARTITION BY symbol ORDER BY time) FROM candles`
---
## Part 3: Returns and risk (series)
These run over the rows in time order, like `sma`. Use `OVER (PARTITION BY symbol ORDER BY time)` for many symbols.
| Name | Syntax | Meaning | First value / NULLs |
|---|---|---|---|
| `pct_return` (`returns`, `percent_return`) | `pct_return(price [, k])` | price / price k rows before - 1 | row k+1; NULL when a price is NULL or the old one is 0 |
| `log_return` | `log_return(price [, k])` | ln(price / price k rows before) | row k+1; NULL when the ratio is 0 or less |
| `pct_change` | `pct_change(price)` | the same as `pct_return × 100` (chapter 5) | row 2 |
| `cum_return` | `cum_return(price)` | price / first price - 1 | row 1 |
| `drawdown` | `drawdown(price)` | price / highest price so far - 1 | row 1 |
| `volatility` | `volatility(price, n [, periods])` | standard deviation of the last n log returns × √periods | row n+1 |
| `zscore` | `zscore(x [, n])` | (x - average) / standard deviation | with n: row n |
| `minmax_scale` | `minmax_scale(x [, n])` | (x - min) / (max - min), 0 to 1 | with n: row n |
| `max_drawdown` | `max_drawdown(price) OVER (...)` | the aggregate of Part 1 as a running value | row 1 |
#### pct_return
- **Syntax:** `pct_return(price [, k])`; also `returns(price [, k])` and `percent_return(price [, k])`
- **Description:** simple return over k rows (default 1): price / price k rows before - 1. 0.02 means +2 %. `pct_change` gives the same in percent.
- **Returns:** DOUBLE. **NULLs:** NULL for the first k rows, when either price is NULL, or when the old price is 0.
- **Example:** `SELECT time, symbol, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) FROM candles`
#### log_return
- **Syntax:** `log_return(price [, k])`
- **Description:** ln(price / price k rows before). Log returns add up over time: the sum of daily log returns is the log return of the whole period.
- **Returns:** DOUBLE. **NULLs:** NULL for the first k rows, when a price is NULL, or when the ratio is 0 or less.
- **Example:** `SELECT time, log_return(close, 5) OVER (PARTITION BY symbol ORDER BY time) AS week_log_return FROM candles`
#### cum_return
- **Syntax:** `cum_return(price)`
- **Description:** return since the first row of the partition: price / first price - 1.
- **Returns:** DOUBLE. **NULLs:** NULL for a NULL price; NULL values before the first price are skipped. NULL when the first price is 0.
- **Example:** `SELECT time, symbol, round(cum_return(close) OVER (PARTITION BY symbol ORDER BY time) * 100, 1) AS pct_since_start FROM candles`
#### drawdown
- **Syntax:** `drawdown(price)`
- **Description:** how far the price is below its highest price so far: price / running max - 1. 0 at a new high, -0.1 when 10 % below the high.
- **Returns:** DOUBLE, 0 or less. **NULLs:** NULL for a NULL price; NULL while the highest price is 0 or less.
- **Example:** see [Drawdown](#drawdown) in the cookbook.
#### volatility
- **Syntax:** `volatility(price, n [, periods])`
- **Description:** rolling volatility: the sample standard deviation of the last n log returns, times √periods. periods default 1 (daily volatility); use 240 or 252 (trading days) for yearly volatility.
- **Arguments:** n: whole number, 2 or more. periods: a number more than 0.
- **Returns:** DOUBLE. **NULLs:** NULL for the first n rows and while one of the last n+1 prices is NULL or 0 or less.
- **Example:** see [20-day volatility](#20-day-volatility).
#### zscore
- **Syntax:** `zscore(x [, n])`
- **Description:** how many standard deviations x is away from the average. With n: the average and the sample standard deviation of the last n rows. Without n: of the frame (the whole partition without ORDER BY, or with a `ROWS`/`RANGE` frame).
- **Returns:** DOUBLE. **NULLs:** NULL for a NULL x. With n: NULL for the first n - 1 rows and while a NULL is in the last n rows. Without n: NULL values in the frame are skipped. NULL when the standard deviation is 0.
- **Example:** unusual volume: `SELECT time, symbol, zscore(volume, 20) OVER (PARTITION BY symbol ORDER BY time) AS vol_z FROM candles`
#### minmax_scale
- **Syntax:** `minmax_scale(x [, n])`
- **Description:** (x - min) / (max - min) of the last n rows (or of the frame without n). 0 = at the low, 1 = at the high.
- **Returns:** DOUBLE from 0 to 1. **NULLs:** as `zscore`; NULL when max = min.
- **Example:** where is the close in its 52-week range? `SELECT time, minmax_scale(close, 240) OVER (PARTITION BY symbol ORDER BY time) FROM candles`
---
## Part 4: Technical indicators
All indicators are series functions over candles in time order. Most follow **TA-Lib**: the same formulas, and the first value on the same row as TA-Lib (the "warm-up" rows before it are NULL). Where TA-Lib versions differ for flat prices (a window where high = low, or no price change), KDB follows TA-Lib 0.6. The table says whose definition each one uses.
NULL rules for indicators:
- **Window indicators** (a fixed number of rows: WMA, Bollinger, Donchian, stochastic %K, CCI, Williams %R, Aroon, linear regression, ...): the result is NULL while a NULL input is inside the window, and comes back after it.
- **Running indicators** (they carry a value from row to row: EMA-based ones, MACD, KAMA, ADX, Parabolic SAR, OBV, A/D, MFI, SuperTrend, Heikin-Ashi): a NULL input makes the result NULL **from that row to the end** of the partition. Remove bad rows with `WHERE close IS NOT NULL` first.
- A period must be a whole number constant. `wma(close, volume)` is an error.
Defaults: arguments in `[ ]` can be left out; the default is shown after `=`.
### Moving averages
| Name | Syntax | Definition | First value (row) |
|---|---|---|---|
| `sma` | `sma(x, n)` | chapter 5 | n |
| `ema` | `ema(x, n)` | chapter 5 (TA-Lib) | n |
| `wma` | `wma(x, n)` | TA-Lib WMA: weights 1..n, newest heaviest | n |
| `dema` | `dema(x, n)` | TA-Lib DEMA: 2·EMA - EMA(EMA) | 2n - 1 |
| `tema` | `tema(x, n)` | TA-Lib TEMA: 3·EMA - 3·EMA(EMA) + EMA(EMA(EMA)) | 3n - 2 |
| `trima` | `trima(x, n)` | TA-Lib TRIMA: triangular weights | n |
| `kama` | `kama(x, n)` | TA-Lib KAMA (fast 2, slow 30) | n + 1 |
| `hma` | `hma(x, n)` | Hull: WMA(2·WMA(x, n/2) - WMA(x, n), √n) | n + ⌊√n⌋ - 1 |
| `vwma` | `vwma(price, volume, n)` | Σ(price·volume) / Σvolume of n rows | n |
#### wma
- **Syntax:** `wma(x, n)`
- **Description:** weighted moving average of the last n rows. The newest row has weight n, the oldest weight 1 (TA-Lib `WMA`).
- **Returns:** DOUBLE. **NULLs:** window rule; first value on row n.
- **Example:** `SELECT time, round(wma(close, 10) OVER (PARTITION BY symbol ORDER BY time), 2) FROM candles`
#### dema
- **Syntax:** `dema(x, n)`
- **Description:** double exponential moving average: 2·EMA(x) - EMA(EMA(x)), both EMAs as TA-Lib (started with an SMA). Reacts faster than EMA.
- **Returns:** DOUBLE. **NULLs:** running rule; first value on row 2n - 1.
- **Example:** `SELECT time, dema(close, 10) FROM candles WHERE symbol = 'CITY'`
#### tema
- **Syntax:** `tema(x, n)`
- **Description:** triple exponential moving average: 3·EMA1 - 3·EMA2 + EMA3, where EMA2 is the EMA of EMA1 and EMA3 the EMA of EMA2 (TA-Lib `TEMA`).
- **Returns:** DOUBLE. **NULLs:** running rule; first value on row 3n - 2.
- **Example:** `SELECT time, tema(close, 10) FROM candles WHERE symbol = 'CITY'`
#### trima
- **Syntax:** `trima(x, n)`
- **Description:** triangular moving average: the middle rows get the most weight (weights 1, 2, ..., 2, 1). The same as TA-Lib `TRIMA` (an SMA of an SMA).
- **Returns:** DOUBLE. **NULLs:** window rule; first value on row n.
- **Example:** `SELECT time, trima(close, 10) FROM candles WHERE symbol = 'COAST'`
#### kama
- **Syntax:** `kama(x, n)`
- **Description:** Kaufman adaptive moving average (TA-Lib `KAMA`). The efficiency ratio over n rows (net change / sum of all changes) picks a speed between a 2-row and a 30-row EMA. It follows trends and stays flat in noise.
- **Returns:** DOUBLE. **NULLs:** running rule; first value on row n + 1.
- **Example:** `SELECT time, kama(close, 10) FROM candles WHERE symbol = 'VOLT'`
#### hma
- **Syntax:** `hma(x, n)`
- **Description:** Hull moving average (Alan Hull; not in TA-Lib): WMA(2·WMA(x, n/2) - WMA(x, n), ⌊√n⌋), with n/2 rounded down. Smooth and fast.
- **Arguments:** n: 2 or more.
- **Returns:** DOUBLE. **NULLs:** window rule; first value on row n + ⌊√n⌋ - 1.
- **Example:** `SELECT time, hma(close, 16) FROM candles WHERE symbol = 'TELCO'`
#### vwma
- **Syntax:** `vwma(price, volume, n)`
- **Description:** volume-weighted moving average of the last n rows: Σ(price × volume) / Σvolume (TradingView `ta.vwma`).
- **Returns:** DOUBLE. **NULLs:** window rule; NULL when the volumes add up to 0.
- **Example:** `SELECT time, vwma(close, volume, 20) FROM candles WHERE symbol = 'CITY'`
VWAP for a trading session or a rolling window needs no new function: `vwap(close, volume) OVER (PARTITION BY symbol, date_trunc('day', time) ORDER BY time)` (session, on minute candles) and `vwap(close, volume) OVER (PARTITION BY symbol ORDER BY time ROWS 19 PRECEDING)` (rolling).
### MACD
| Name | Syntax | Definition | First value (row) |
|---|---|---|---|
| `macd` | `macd(x [, fast = 12, slow = 26, signal = 9])` | TA-Lib MACD line | slow + signal - 1 (34) |
| `macd_signal` | `macd_signal(x [, 12, 26, 9])` | EMA of the MACD line | slow + signal - 1 |
| `macd_hist` | `macd_hist(x [, 12, 26, 9])` | line - signal | slow + signal - 1 |
#### macd
- **Syntax:** `macd(x [, fast [, slow [, signal]]])`, defaults 12, 26, 9
- **Description:** the MACD line: EMA(x, fast) - EMA(x, slow), exactly as TA-Lib `MACD`: both EMAs start on row `slow` (the fast EMA starts with the SMA of the `fast` rows ending there), and all three outputs start on row slow + signal - 1, where the signal line has its first value. (TradingView shows the line 8 rows earlier; the values after row 34 agree once the EMAs have settled.) If slow is smaller than fast they are swapped, as in TA-Lib.
- **Returns:** DOUBLE. **NULLs:** running rule.
- **Example:** see [MACD crossover signals](#macd-crossover-signals).
#### macd_signal
- **Syntax:** `macd_signal(x [, fast [, slow [, signal]]])`
- **Description:** the signal line: an EMA (TA-Lib style) of the MACD line over `signal` rows.
- **Returns:** DOUBLE. **NULLs:** running rule.
- **Example:** `SELECT time, macd(close), macd_signal(close) FROM candles WHERE symbol = 'CITY'`
#### macd_hist
- **Syntax:** `macd_hist(x [, fast [, slow [, signal]]])`
- **Description:** the histogram: MACD line - signal line. It changes sign when the lines cross.
- **Returns:** DOUBLE. **NULLs:** running rule.
- **Example:** `SELECT time, macd_hist(close, 12, 26, 9) FROM candles WHERE symbol = 'CITY'`
### Oscillators
| Name | Syntax | Definition | First value (row) |
|---|---|---|---|
| `rsi` | `rsi(x, n)` | chapter 5 (Wilder, TA-Lib start) | n + 1 |
| `stoch_k` | `stoch_k(high, low, close [, k = 14, k_smooth = 3, d = 3])` | TA-Lib STOCH slow %K | k + k_smooth + d - 2 |
| `stoch_d` | `stoch_d(high, low, close [, 14, 3, 3])` | TA-Lib STOCH slow %D | k + k_smooth + d - 2 |
| `stochf_k` | `stochf_k(high, low, close [, n = 14])` | raw (fast) %K | n |
| `stochrsi_k` | `stochrsi_k(x [, n = 14, k = 5, d = 3])` | TA-Lib STOCHRSI fast %K | n + k + d - 1 |
| `stochrsi_d` | `stochrsi_d(x [, 14, 5, 3])` | TA-Lib STOCHRSI fast %D | n + k + d - 1 |
| `willr` (`williams_r`) | `willr(high, low, close [, n = 14])` | TA-Lib WILLR, -100 to 0 | n |
| `cci` | `cci(high, low, close [, n = 20])` | TA-Lib CCI | n |
| `roc` | `roc(x [, n = 10])` | TA-Lib ROC: (x / x n rows before - 1) × 100 | n + 1 |
| `rocp` | `rocp(x [, n = 10])` | (x - old) / old | n + 1 |
| `rocr` | `rocr(x [, n = 10])` | x / old | n + 1 |
| `mom` (`momentum`) | `mom(x [, n = 10])` | x - x n rows before | n + 1 |
| `trix` | `trix(x [, n = 30])` | TA-Lib TRIX: 1-row ROC of a triple EMA | 3n - 1 |
#### stoch_k
- **Syntax:** `stoch_k(high, low, close [, k [, k_smooth [, d]]])`, defaults 14, 3, 3
- **Description:** slow stochastic %K (TA-Lib `STOCH` with SMA smoothing): raw %K = 100 × (close - lowest low of k rows) / (highest high - lowest low), 0 when the high equals the low; slow %K = SMA of raw %K over k_smooth rows. As in TA-Lib, %K and %D both start on row k + k_smooth + d - 2. TA-Lib's own default k is 5; KDB uses the common 14.
- **Returns:** DOUBLE, 0 to 100. **NULLs:** window rule.
- **Example:** `SELECT time, stoch_k(high, low, close), stoch_d(high, low, close) FROM candles WHERE symbol = 'COAST'`
#### stoch_d
- **Syntax:** `stoch_d(high, low, close [, k [, k_smooth [, d]]])`
- **Description:** slow %D: SMA of slow %K over d rows.
- **Returns:** DOUBLE, 0 to 100. **NULLs:** window rule.
- **Example:** `SELECT time FROM (SELECT time, stoch_k(high, low, close) AS k, stoch_d(high, low, close) AS d FROM candles WHERE symbol = 'COAST') s WHERE k > 80 AND d > 80`
#### stochf_k
- **Syntax:** `stochf_k(high, low, close [, n])`
- **Description:** the raw (fast) stochastic %K of the last n rows, without smoothing.
- **Returns:** DOUBLE, 0 to 100. **NULLs:** window rule; first value on row n.
- **Example:** `SELECT time, stochf_k(high, low, close, 5) FROM candles WHERE symbol = 'COAST'`
#### stochrsi_k
- **Syntax:** `stochrsi_k(x [, n [, k [, d]]])`, defaults 14, 5, 3 (TA-Lib's defaults)
- **Description:** stochastic RSI %K (TA-Lib `STOCHRSI`): the fast stochastic of RSI(n) over k rows. The RSI is TA-Lib's (a flat start gives 0).
- **Returns:** DOUBLE, 0 to 100. **NULLs:** running rule (the RSI); first value on row n + k + d - 1.
- **Example:** `SELECT time, stochrsi_k(close), stochrsi_d(close) FROM candles WHERE symbol = 'VOLT'`
#### stochrsi_d
- **Syntax:** `stochrsi_d(x [, n [, k [, d]]])`
- **Description:** SMA of stochastic RSI %K over d rows.
- **Returns:** DOUBLE, 0 to 100. **NULLs:** as `stochrsi_k`.
- **Example:** `SELECT time, stochrsi_d(close, 14, 14, 3) FROM candles WHERE symbol = 'VOLT'`
#### willr
- **Syntax:** `willr(high, low, close [, n])` (also `williams_r`), default 14
- **Description:** Williams %R (TA-Lib `WILLR`): -100 × (highest high - close) / (highest high - lowest low) over n rows. 0 = at the high, -100 = at the low; 0 when the high equals the low.
- **Returns:** DOUBLE, -100 to 0. **NULLs:** window rule; first value on row n.
- **Example:** `SELECT time, willr(high, low, close) FROM candles WHERE symbol = 'TELCO'`
#### cci
- **Syntax:** `cci(high, low, close [, n])`, default 20
- **Description:** commodity channel index (TA-Lib `CCI`): (typical price - its n-row average) / (0.015 × mean absolute deviation). Typical price = (high + low + close) / 3. 0 when the deviation is 0.
- **Returns:** DOUBLE. **NULLs:** window rule; first value on row n.
- **Example:** `SELECT time, cci(high, low, close) FROM candles WHERE symbol = 'TELCO'`
#### roc
- **Syntax:** `roc(x [, n])`, default 10
- **Description:** rate of change in percent (TA-Lib `ROC`): (x / x n rows before - 1) × 100.
- **Returns:** DOUBLE. **NULLs:** NULL when a value is NULL or the old value is 0 (TA-Lib gives 0 there).
- **Example:** `SELECT time, roc(close, 10) FROM candles WHERE symbol = 'CITY'`
#### rocp
- **Syntax:** `rocp(x [, n])`
- **Description:** rate of change as a fraction (TA-Lib `ROCP`): (x - old) / old.
- **Returns:** DOUBLE. **NULLs:** as `roc`.
- **Example:** `SELECT time, rocp(close) FROM candles WHERE symbol = 'CITY'`
#### rocr
- **Syntax:** `rocr(x [, n])`
- **Description:** rate of change ratio (TA-Lib `ROCR`): x / old.
- **Returns:** DOUBLE. **NULLs:** as `roc`.
- **Example:** `SELECT time, rocr(close, 3) FROM candles WHERE symbol = 'CITY'`
#### mom
- **Syntax:** `mom(x [, n])` (also `momentum`), default 10
- **Description:** momentum (TA-Lib `MOM`): x - x n rows before.
- **Returns:** DOUBLE. **NULLs:** NULL when a value is NULL.
- **Example:** `SELECT time, momentum(close, 5) FROM candles WHERE symbol = 'CITY'`
#### trix
- **Syntax:** `trix(x [, n])`, default 30
- **Description:** TRIX (TA-Lib): the 1-row rate of change in percent of EMA(EMA(EMA(x, n))).
- **Returns:** DOUBLE. **NULLs:** running rule; first value on row 3n - 1.
- **Example:** `SELECT time, trix(close, 10) FROM candles WHERE symbol = 'VOLT'`
### Volatility and bands
| Name | Syntax | Definition | First value (row) |
|---|---|---|---|
| `atr` | `atr(high, low, close, n)` | chapter 5 (TA-Lib) | n + 1 |
| `natr` | `natr(high, low, close [, n = 14])` | TA-Lib NATR: ATR / close × 100 | n + 1 |
| `trange` (`true_range`) | `trange(high, low, close)` | TA-Lib TRANGE | 2 |
| `bb_upper`, `bb_middle`, `bb_lower` | `bb_upper(x [, n = 20, k = 2])` | Bollinger: SMA ± k × population std. dev. | n |
| `bb_percent_b` | `bb_percent_b(x [, 20, 2])` | (x - lower) / (upper - lower) | n |
| `bb_width` | `bb_width(x [, 20, 2])` | (upper - lower) / middle | n |
| `kc_upper`, `kc_middle`, `kc_lower` | `kc_upper(high, low, close [, n = 20, mult = 2, atr_n = 10])` | Keltner: EMA(close, n) ± mult × ATR(atr_n) | max(n, atr_n + 1) |
| `dc_upper`, `dc_middle`, `dc_lower` | `dc_upper(high, low [, n = 20])` | Donchian: highest high / middle / lowest low | n |
#### natr
- **Syntax:** `natr(high, low, close [, n])`, default 14
- **Description:** normalized ATR (TA-Lib `NATR`): ATR(n) / close × 100, so symbols with different prices can be compared.
- **Returns:** DOUBLE. **NULLs:** running rule; NULL when close is 0.
- **Example:** `SELECT symbol, natr(high, low, close) OVER (PARTITION BY symbol ORDER BY time) FROM candles`
#### trange
- **Syntax:** `trange(high, low, close)` (also `true_range`)
- **Description:** true range (TA-Lib `TRANGE`): the biggest of high - low, |high - previous close|, |low - previous close|. The first row is NULL (no previous close), as in TA-Lib.
- **Returns:** DOUBLE. **NULLs:** NULL when a value of this row or the previous close is NULL.
- **Example:** `SELECT time, trange(high, low, close) FROM candles WHERE symbol = 'CITY'`
#### bb_upper
- **Syntax:** `bb_upper(x [, n [, k]])`, `bb_middle(x [, n [, k]])`, `bb_lower(x [, n [, k]])`; defaults 20, 2
- **Description:** Bollinger bands: middle = SMA(x, n); upper and lower = middle ± k × the **population** standard deviation of the last n values (as John Bollinger and TA-Lib `BBANDS` with SMA). TA-Lib's own default n is 5; KDB uses the common 20.
- **Returns:** DOUBLE. **NULLs:** window rule; first value on row n.
- **Example:** `SELECT time, close, bb_lower(close), bb_middle(close), bb_upper(close) FROM candles WHERE symbol = 'CITY'`
#### bb_percent_b
- **Syntax:** `bb_percent_b(x [, n [, k]])`
- **Description:** %b: where x is inside the bands: 0 = at the lower band, 1 = at the upper band (it can be below 0 or above 1).
- **Returns:** DOUBLE. **NULLs:** window rule; NULL when the bands are equal (no change).
- **Example:** `SELECT time, bb_percent_b(close) FROM candles WHERE symbol = 'CITY'`
#### bb_width
- **Syntax:** `bb_width(x [, n [, k]])`
- **Description:** band width: (upper - lower) / middle, a fraction (multiply by 100 for percent). Small values mean a "squeeze" (quiet prices).
- **Returns:** DOUBLE. **NULLs:** window rule; NULL when the middle is 0.
- **Example:** see [Bollinger squeeze filter](#bollinger-squeeze-filter).
#### kc_upper
- **Syntax:** `kc_upper(high, low, close [, n [, mult [, atr_n]]])`, `kc_lower(...)`, `kc_middle(high, low, close [, n])`; defaults 20, 2, 10
- **Description:** Keltner channels (the Linda Raschke version): middle = EMA(close, n) as TA-Lib; upper and lower = middle ± mult × ATR(atr_n) (TA-Lib ATR).
- **Returns:** DOUBLE. **NULLs:** running rule; first value when both the EMA and the ATR have values.
- **Example:** `SELECT time, kc_lower(high, low, close), kc_upper(high, low, close) FROM candles WHERE symbol = 'VOLT'`
#### dc_upper
- **Syntax:** `dc_upper(high, low [, n])`, `dc_lower(high, low [, n])`, `dc_middle(high, low [, n])`; default 20
- **Description:** Donchian channels: the highest high, the lowest low, and their middle, over the last n rows (this row included, as TradingView).
- **Returns:** DOUBLE. **NULLs:** window rule; first value on row n.
- **Example:** new 20-day highs: `SELECT time, symbol FROM (SELECT time, symbol, high, dc_upper(high, low, 20) OVER (PARTITION BY symbol ORDER BY time) AS top FROM candles) x WHERE high = top`
### Trend strength and direction
| Name | Syntax | Definition | First value (row) |
|---|---|---|---|
| `plus_di`, `minus_di` | `plus_di(high, low, close [, n = 14])` | TA-Lib PLUS_DI / MINUS_DI | n + 1 |
| `dx` | `dx(high, low, close [, n = 14])` | TA-Lib DX | n + 1 |
| `adx` | `adx(high, low, close [, n = 14])` | TA-Lib ADX | 2n |
| `adxr` | `adxr(high, low, close [, n = 14])` | TA-Lib ADXR | 3n - 1 |
| `aroon_up` | `aroon_up(high [, n = 14])` | TA-Lib AROON up | n + 1 |
| `aroon_down` | `aroon_down(low [, n = 14])` | TA-Lib AROON down | n + 1 |
| `aroon_osc` | `aroon_osc(high, low [, n = 14])` | TA-Lib AROONOSC | n + 1 |
| `sar` (`parabolic_sar`) | `sar(high, low [, acceleration = 0.02, maximum = 0.2])` | TA-Lib SAR | 2 |
| `supertrend` | `supertrend(high, low, close [, n = 10, multiplier = 3])` | TradingView `ta.supertrend` | n |
| `supertrend_direction` | `supertrend_direction(high, low, close [, 10, 3])` | 1 up, -1 down | n |
| `linearreg` (`linreg`) | `linearreg(x [, n = 14])` | TA-Lib LINEARREG | n |
| `linearreg_slope` | `linearreg_slope(x [, 14])` | TA-Lib LINEARREG_SLOPE | n |
| `linearreg_intercept` | `linearreg_intercept(x [, 14])` | TA-Lib LINEARREG_INTERCEPT | n |
| `linearreg_angle` | `linearreg_angle(x [, 14])` | TA-Lib LINEARREG_ANGLE (degrees) | n |
| `tsf` | `tsf(x [, 14])` | TA-Lib TSF: the line's value one row ahead | n |
#### plus_di
- **Syntax:** `plus_di(high, low, close [, n])`, `minus_di(high, low, close [, n])`; default 14
- **Description:** directional indicators (TA-Lib `PLUS_DI`, `MINUS_DI`): Wilder-smoothed +DM (or -DM) divided by the smoothed true range, × 100. As TA-Lib, the first n - 1 moves are added up and the n-th is the first smoothing step.
- **Arguments:** n: 2 or more.
- **Returns:** DOUBLE, 0 to 100. **NULLs:** running rule; 0 when the true range is 0.
- **Example:** `SELECT time, plus_di(high, low, close), minus_di(high, low, close) FROM candles WHERE symbol = 'CITY'`
#### dx
- **Syntax:** `dx(high, low, close [, n])`
- **Description:** directional movement index (TA-Lib `DX`): 100 × |+DI - -DI| / (+DI + -DI).
- **Returns:** DOUBLE, 0 to 100. **NULLs:** running rule. When it cannot be computed (no movement) the value before it is kept (0 on the first row).
- **Example:** `SELECT time, dx(high, low, close) FROM candles WHERE symbol = 'CITY'`
#### adx
- **Syntax:** `adx(high, low, close [, n])`, default 14
- **Description:** average directional index (TA-Lib `ADX`): the average of the first n DX values, then Wilder smoothing. Above 25 is often read as a strong trend.
- **Returns:** DOUBLE, 0 to 100. **NULLs:** running rule; first value on row 2n.
- **Example:** `SELECT time, symbol, adx(high, low, close) OVER (PARTITION BY symbol ORDER BY time) FROM candles`
#### adxr
- **Syntax:** `adxr(high, low, close [, n])`
- **Description:** ADX rating (TA-Lib `ADXR`): (ADX + ADX n - 1 rows before) / 2.
- **Returns:** DOUBLE. **NULLs:** running rule; first value on row 3n - 1.
- **Example:** `SELECT time, adxr(high, low, close) FROM candles WHERE symbol = 'CITY'`
#### aroon_up
- **Syntax:** `aroon_up(high [, n])`, `aroon_down(low [, n])`; default 14
- **Description:** Aroon (TA-Lib `AROON`): 100 × (n - rows since the highest high) / n, over the last n + 1 rows. When the extreme appears twice, the newest one counts (as TA-Lib). `aroon_down` uses the lowest low.
- **Returns:** DOUBLE, 0 to 100. **NULLs:** window rule; first value on row n + 1.
- **Example:** `SELECT time, aroon_up(high), aroon_down(low) FROM candles WHERE symbol = 'COAST'`
#### aroon_osc
- **Syntax:** `aroon_osc(high, low [, n])`
- **Description:** Aroon oscillator (TA-Lib `AROONOSC`): aroon up - aroon down.
- **Returns:** DOUBLE, -100 to 100. **NULLs:** window rule.
- **Example:** `SELECT time, aroon_osc(high, low, 25) FROM candles WHERE symbol = 'COAST'`
#### sar
- **Syntax:** `sar(high, low [, acceleration [, maximum]])` (also `parabolic_sar`), defaults 0.02 and 0.2
- **Description:** Parabolic SAR (TA-Lib `SAR`). The first direction comes from the first two candles (down when the low fell more than the high rose). The value is a stop level below the price in an up trend and above it in a down trend; it flips when the price crosses it.
- **Returns:** DOUBLE. **NULLs:** running rule; first value on row 2.
- **Example:** `SELECT time, close, sar(high, low) FROM candles WHERE symbol = 'TELCO'`
#### supertrend
- **Syntax:** `supertrend(high, low, close [, n [, multiplier]])`, defaults 10 and 3
- **Description:** SuperTrend exactly as TradingView's `ta.supertrend(multiplier, n)`: bands (high + low) / 2 ± multiplier × ATR, where the ATR is TradingView's (`ta.rma` of the true range, the first true range = high - low). The line is the lower band in an up trend and the upper band in a down trend.
- **Returns:** DOUBLE. **NULLs:** running rule; first value on row n.
- **Example:** `SELECT time, close, supertrend(high, low, close) FROM candles WHERE symbol = 'VOLT'`
#### supertrend_direction
- **Syntax:** `supertrend_direction(high, low, close [, n [, multiplier]])`
- **Description:** the trend of `supertrend`: **1 = up, -1 = down**. (TradingView's `direction` uses the opposite signs: -1 for up.)
- **Returns:** LONG. **NULLs:** running rule.
- **Example:** the days the trend changed (`lag` needs its own level, and the filter one more): `SELECT time, d FROM (SELECT time, d, lag(d) AS prev FROM (SELECT time, supertrend_direction(high, low, close) AS d FROM candles WHERE symbol = 'VOLT') s) x WHERE d != prev`
#### linearreg
- **Syntax:** `linearreg(x [, n])` (also `linreg`), default 14
- **Description:** linear regression (TA-Lib `LINEARREG`): the least-squares line through the last n values, and its value on the current row.
- **Returns:** DOUBLE. **NULLs:** window rule; first value on row n.
- **Example:** `SELECT time, close, linearreg(close, 20) FROM candles WHERE symbol = 'CITY'`
#### linearreg_slope
- **Syntax:** `linearreg_slope(x [, n])` (also `linreg_slope`)
- **Description:** the slope of that line: change per row (TA-Lib `LINEARREG_SLOPE`).
- **Returns:** DOUBLE. **NULLs:** window rule.
- **Example:** `SELECT symbol, linearreg_slope(close, 20) OVER (PARTITION BY symbol ORDER BY time) FROM candles`
#### linearreg_intercept
- **Syntax:** `linearreg_intercept(x [, n])` (also `linreg_intercept`)
- **Description:** the line's value on the oldest row of the window (TA-Lib `LINEARREG_INTERCEPT`).
- **Returns:** DOUBLE. **NULLs:** window rule.
- **Example:** `SELECT time, linearreg_intercept(close) FROM candles WHERE symbol = 'CITY'`
#### linearreg_angle
- **Syntax:** `linearreg_angle(x [, n])` (also `linreg_angle`)
- **Description:** atan(slope) in degrees (TA-Lib `LINEARREG_ANGLE`). It depends on the price scale, so compare it only within one symbol.
- **Returns:** DOUBLE, -90 to 90. **NULLs:** window rule.
- **Example:** `SELECT time, linearreg_angle(close) FROM candles WHERE symbol = 'CITY'`
#### tsf
- **Syntax:** `tsf(x [, n])`
- **Description:** time series forecast (TA-Lib `TSF`): the regression line's value one row after the current row.
- **Returns:** DOUBLE. **NULLs:** window rule.
- **Example:** `SELECT time, close, tsf(close, 14) FROM candles WHERE symbol = 'CITY'`
### Ichimoku
| Name | Syntax | Meaning | First value (row) |
|---|---|---|---|
| `ichimoku_tenkan` | `ichimoku_tenkan(high, low [, n = 9])` | (highest high + lowest low) / 2 of n rows | n |
| `ichimoku_kijun` | `ichimoku_kijun(high, low [, n = 26])` | the same over 26 rows | n |
| `ichimoku_senkou_a` | `ichimoku_senkou_a(high, low [, tenkan = 9, kijun = 26, shift = 26])` | (tenkan + kijun) / 2 from `shift` rows before | kijun + shift |
| `ichimoku_senkou_b` | `ichimoku_senkou_b(high, low [, n = 52, shift = 26])` | 52-row middle from `shift` rows before | n + shift |
| `ichimoku_chikou` | `ichimoku_chikou(close [, shift = 26])` | the close `shift` rows **after** this row | NULL on the last `shift` rows |
#### ichimoku_tenkan
- **Syntax:** `ichimoku_tenkan(high, low [, n])`
- **Description:** conversion line: (highest high + lowest low) / 2 over the last n rows.
- **Returns:** DOUBLE. **NULLs:** window rule.
- **Example:** `SELECT time, ichimoku_tenkan(high, low), ichimoku_kijun(high, low) FROM candles WHERE symbol = 'CITY'`
#### ichimoku_kijun
- **Syntax:** `ichimoku_kijun(high, low [, n])`
- **Description:** base line: the same as tenkan over 26 rows.
- **Returns:** DOUBLE. **NULLs:** window rule.
- **Example:** as `ichimoku_tenkan`.
#### ichimoku_senkou_a
- **Syntax:** `ichimoku_senkou_a(high, low [, tenkan [, kijun [, shift]]])`
- **Description:** leading span A. A chart draws it `shift` rows **ahead**; a table has no future rows, so KDB puts on each row the value that was computed `shift` rows before (the cloud as drawn over that row). Pass shift 25 to match TradingView, which draws it 25 bars ahead.
- **Returns:** DOUBLE. **NULLs:** window rule.
- **Example:** price above the cloud: `SELECT time FROM (SELECT time, close, ichimoku_senkou_a(high, low) AS a, ichimoku_senkou_b(high, low) AS b FROM candles WHERE symbol = 'CITY') x WHERE close > greatest(a, b)`
#### ichimoku_senkou_b
- **Syntax:** `ichimoku_senkou_b(high, low [, n [, shift]])`
- **Description:** leading span B: the n-row middle price (default 52) from `shift` rows before.
- **Returns:** DOUBLE. **NULLs:** window rule.
- **Example:** as `ichimoku_senkou_a`.
#### ichimoku_chikou
- **Syntax:** `ichimoku_chikou(close [, shift])`
- **Description:** lagging span: a chart draws today's close `shift` rows back. On a table row it is the close `shift` rows **later** (like `lead(close, 26)`). The last `shift` rows are NULL. It looks into the future: never use it for a trading signal on the newest day.
- **Returns:** DOUBLE. **NULLs:** NULL on the last `shift` rows.
- **Example:** `SELECT time, ichimoku_chikou(close) FROM candles WHERE symbol = 'CITY'`
### Volume
| Name | Syntax | Definition | First value (row) |
|---|---|---|---|
| `obv` | `obv(close, volume)` | TA-Lib OBV (starts with the first volume) | 1 |
| `ad` (`ad_line`) | `ad(high, low, close, volume)` | TA-Lib AD (accumulation/distribution) | 1 |
| `cmf` | `cmf(high, low, close, volume [, n = 20])` | Chaikin money flow | n |
| `adosc` (`chaikin_osc`) | `adosc(high, low, close, volume [, fast = 3, slow = 10])` | TA-Lib ADOSC | slow |
| `mfi` | `mfi(high, low, close, volume [, n = 14])` | TA-Lib MFI | n + 1 |
| `efi` (`force_index`) | `efi(close, volume [, n = 13])` | Elder's force index, EMA of (change × volume) | n + 1 |
| `eom` (`ease_of_movement`) | `eom(high, low, volume [, n = 14, divisor = 100000000])` | ease of movement (pandas-ta) | n + 1 |
#### obv
- **Syntax:** `obv(close, volume)`
- **Description:** on-balance volume (TA-Lib `OBV`): starts with the first row's volume, then adds the volume on up closes and subtracts it on down closes.
- **Returns:** DOUBLE. **NULLs:** running rule.
- **Example:** `SELECT time, obv(close, volume) OVER (PARTITION BY symbol ORDER BY time) FROM candles`
#### ad
- **Syntax:** `ad(high, low, close, volume)` (also `ad_line`)
- **Description:** accumulation/distribution line (TA-Lib `AD`): running sum of ((close - low) - (high - close)) / (high - low) × volume. Rows with high = low add nothing.
- **Returns:** DOUBLE. **NULLs:** running rule.
- **Example:** `SELECT time, ad(high, low, close, volume) FROM candles WHERE symbol = 'COAST'`
#### cmf
- **Syntax:** `cmf(high, low, close, volume [, n])`, default 20
- **Description:** Chaikin money flow: Σ(money flow multiplier × volume) / Σvolume over n rows; the multiplier is ((close - low) - (high - close)) / (high - low), 0 when high = low.
- **Returns:** DOUBLE, -1 to 1. **NULLs:** window rule; NULL when the volumes add up to 0.
- **Example:** `SELECT time, cmf(high, low, close, volume) FROM candles WHERE symbol = 'COAST'`
#### adosc
- **Syntax:** `adosc(high, low, close, volume [, fast [, slow]])` (also `chaikin_osc`), defaults 3 and 10
- **Description:** Chaikin oscillator (TA-Lib `ADOSC`): EMA(A/D, fast) - EMA(A/D, slow). As in TA-Lib, both EMAs start from the first A/D value (not an SMA).
- **Returns:** DOUBLE. **NULLs:** running rule; first value on row max(fast, slow).
- **Example:** `SELECT time, adosc(high, low, close, volume) FROM candles WHERE symbol = 'COAST'`
#### mfi
- **Syntax:** `mfi(high, low, close, volume [, n])`, default 14
- **Description:** money flow index (TA-Lib `MFI`): a volume-weighted RSI of the typical price. 100 × positive flow / (positive + negative flow) over n rows. A row whose typical price did not change adds no flow; 0 when there is no flow at all (as TA-Lib 0.6).
- **Returns:** DOUBLE, 0 to 100. **NULLs:** running rule; first value on row n + 1.
- **Example:** `SELECT time, mfi(high, low, close, volume) FROM candles WHERE symbol = 'TELCO'`
#### efi
- **Syntax:** `efi(close, volume [, n])` (also `force_index`), default 13
- **Description:** Elder's force index: (close - previous close) × volume, smoothed with an EMA of n rows that starts with an SMA (TA-Lib style EMA).
- **Returns:** DOUBLE. **NULLs:** running rule; first value on row n + 1.
- **Example:** `SELECT time, efi(close, volume) FROM candles WHERE symbol = 'TELCO'`
#### eom
- **Syntax:** `eom(high, low, volume [, n [, divisor]])` (also `ease_of_movement`), defaults 14 and 100000000
- **Description:** ease of movement (as pandas-ta `eom`): distance = middle price - previous middle price; box ratio = (volume / divisor) / (high - low); EOM = SMA(distance / box ratio, n).
- **Returns:** DOUBLE. **NULLs:** window rule; a row with high = low or volume 0 is NULL and makes its windows NULL.
- **Example:** `SELECT time, eom(high, low, volume) FROM candles WHERE symbol = 'TELCO'`
### Heikin-Ashi
| Name | Syntax | Meaning |
|---|---|---|
| `ha_open` | `ha_open(open, high, low, close)` | first row (open + close) / 2, then (previous ha_open + previous ha_close) / 2 |
| `ha_close` | `ha_close(open, high, low, close)` | (open + high + low + close) / 4 |
| `ha_high` | `ha_high(open, high, low, close)` | biggest of high, ha_open, ha_close |
| `ha_low` | `ha_low(open, high, low, close)` | smallest of low, ha_open, ha_close |
#### ha_open
- **Syntax:** `ha_open(open, high, low, close)`
- **Description:** Heikin-Ashi open. All four `ha_` functions take the same four arguments, so a chart can ask for the whole candle at once.
- **Returns:** DOUBLE. **NULLs:** running rule.
- **Example:** `SELECT time, ha_open(open, high, low, close) AS o, ha_high(open, high, low, close) AS h, ha_low(open, high, low, close) AS l, ha_close(open, high, low, close) AS c FROM candles WHERE symbol = 'CITY'`
#### ha_close
- **Syntax:** `ha_close(open, high, low, close)`
- **Description:** Heikin-Ashi close: the average of the four prices of the row.
- **Returns:** DOUBLE. **NULLs:** running rule.
- **Example:** as `ha_open`.
#### ha_high
- **Syntax:** `ha_high(open, high, low, close)`
- **Description:** Heikin-Ashi high: max(high, ha_open, ha_close).
- **Returns:** DOUBLE. **NULLs:** running rule.
- **Example:** as `ha_open`.
#### ha_low
- **Syntax:** `ha_low(open, high, low, close)`
- **Description:** Heikin-Ashi low: min(low, ha_open, ha_close).
- **Returns:** DOUBLE. **NULLs:** running rule.
- **Example:** as `ha_open`.
---
## Part 5: Scalar functions
A NULL argument gives NULL for every function in this part (except `random()` and `setseed()`, which take none or one).
### Math
| Name | Syntax | Meaning | Example | Result |
|---|---|---|---|---|
| `trunc` (`truncate`) | `trunc(x [, d])` | cut toward zero to d decimals | `trunc(-2.7)` | `-2` |
| `cbrt` | `cbrt(x)` | cube root | `cbrt(27)` | `3` |
| `log` | `log(x)`, `log(base, x)` | natural log; log with a base | `log(2, 8)` | `3` |
| `log2` | `log2(x)` | log base 2 | `log2(8)` | `3` |
| `gcd`, `lcm` | `gcd(a, b)` | greatest common divisor, least common multiple | `lcm(4, 6)` | `12` |
| `factorial` | `factorial(n)` | n! (LONG up to 20, DOUBLE above) | `factorial(5)` | `120` |
| `pi` | `pi()` | 3.14159... | | |
| `degrees`, `radians` | `degrees(x)` | radians to degrees and back | `degrees(pi())` | `180` |
| `sin` `cos` `tan` `cot` | `sin(x)` | trigonometry (radians) | `cos(0)` | `1` |
| `asin` `acos` `atan` `atan2` | `atan2(y, x)` | inverse trigonometry | `atan2(1, 1)` | `0.785...` |
| `sinh` `cosh` `tanh` `asinh` `acosh` `atanh` | `tanh(x)` | hyperbolic functions | `tanh(0)` | `0` |
| `width_bucket` | `width_bucket(x, low, high, count)` | bucket number for a histogram | `width_bucket(5.35, 0.024, 10.06, 5)` | `3` |
| `clamp` | `clamp(x, low, high)` | x kept between low and high | `clamp(15, 0, 10)` | `10` |
| `safe_divide` | `safe_divide(a, b)` | a / b, NULL when b is 0 | `safe_divide(1, 0)` | NULL |
| `div0` | `div0(a, b)` | a / b, 0 when b is 0 | `div0(1, 0)` | `0` |
| `percent` | `percent(a, b)` | a / b × 100, NULL when b is 0 | `percent(1, 4)` | `25` |
| `is_nan`, `is_inf`, `is_finite` | `is_finite(x)` | tests for special DOUBLE values | `is_finite(2)` | `true` |
| `random` (`rand`) | `random()` | random DOUBLE, 0 or more and less than 1 | | |
| `setseed` | `setseed(s)` | fixes the random numbers, s from -1 to 1 | | NULL |
| `normal_cdf` | `normal_cdf(x [, mean, sd])` | normal distribution: P(X ≤ x) | `normal_cdf(1.96)` | `0.975...` |
| `normal_pdf` | `normal_pdf(x [, mean, sd])` | normal density | | |
| `normal_inv` | `normal_inv(p [, mean, sd])` | x where P(X ≤ x) = p | `normal_inv(0.975)` | `1.9599...` |
#### trunc
- **Syntax:** `trunc(x [, decimals])` (also `truncate`)
- **Description:** removes digits toward zero: `trunc(2.79, 1)` is 2.7, `trunc(-2.7)` is -2. Whole numbers stay whole numbers.
- **Returns:** LONG for a whole number with decimals ≥ 0, else DOUBLE. **NULLs:** NULL argument gives NULL.
- **Example:** `SELECT trunc(close, 0) FROM candles`
#### cbrt
- **Syntax:** `cbrt(x)`
- **Description:** cube root; also works for negative numbers.
- **Returns:** DOUBLE. **Example:** `SELECT cbrt(-8)` gives -2.
#### log
- **Syntax:** `log(x)` or `log(base, x)`
- **Description:** with one argument the natural log (the same as `ln`, as in QuestDB; PostgreSQL's `log(x)` is base 10, use `log10`). With two arguments the log of x in the given base.
- **Returns:** DOUBLE. **NULLs:** NULL for x ≤ 0, or a base ≤ 0 or equal to 1.
- **Example:** `SELECT log(2, 1024)` gives 10.
#### log2
- **Syntax:** `log2(x)`
- **Description:** log base 2.
- **Returns:** DOUBLE. **NULLs:** NULL for x ≤ 0.
- **Example:** `SELECT log2(8)` gives 3.
#### gcd
- **Syntax:** `gcd(a, b)`, `lcm(a, b)`
- **Description:** greatest common divisor and least common multiple of two whole numbers. `gcd(0, 5)` is 5; `lcm` with a 0 is 0.
- **Returns:** LONG. **NULLs:** NULL argument gives NULL. Error for a number that is not whole, or when the result does not fit in a LONG.
- **Example:** lot sizes: `SELECT lcm(10, 25)` gives 50.
#### factorial
- **Syntax:** `factorial(n)`
- **Description:** 1 × 2 × ... × n. `factorial(0)` is 1.
- **Returns:** LONG for n up to 20, DOUBLE above (NULL above 170). Error for a negative n.
- **Example:** `SELECT factorial(10)` gives 3628800.
#### pi
- **Syntax:** `pi()`
- **Returns:** DOUBLE 3.141592653589793.
- **Example:** `SELECT degrees(pi() / 2)` gives 90.
#### degrees
- **Syntax:** `degrees(radians)`, `radians(degrees)`
- **Description:** converts between radians and degrees.
- **Returns:** DOUBLE. **Example:** `SELECT radians(180)` gives 3.14159....
#### sin
- **Syntax:** `sin(x)`, `cos(x)`, `tan(x)`, `cot(x)`, `asin(x)`, `acos(x)`, `atan(x)`, `atan2(y, x)`, `sinh(x)`, `cosh(x)`, `tanh(x)`, `asinh(x)`, `acosh(x)`, `atanh(x)`
- **Description:** trigonometric and hyperbolic functions; angles in radians. `atan2(y, x)` is the angle of the point (x, y).
- **Returns:** DOUBLE. **NULLs:** NULL outside the domain (`asin(2)`, `acosh(0.5)`, `atanh(1)`).
- **Example:** the sample candles view uses `sin` to make prices.
#### width_bucket
- **Syntax:** `width_bucket(x, low, high, count)`
- **Description:** splits [low, high) into `count` equal buckets and gives the bucket of x, 1 to count. Below low gives 0, at or above high gives count + 1 (as PostgreSQL). low may be bigger than high (buckets then go down).
- **Returns:** LONG. **NULLs:** NULL argument gives NULL. Error when low = high or count < 1.
- **Example:** see [Histograms](#histograms).
#### clamp
- **Syntax:** `clamp(x, low, high)`
- **Description:** low when x < low, high when x > high, else x. Works for numbers, text and times.
- **Returns:** the type of the chosen value. **NULLs:** NULL argument gives NULL. Error when low > high.
- **Example:** `SELECT clamp(pct_change, -10, 10) FROM live_data` (for a market with a daily limit of ±10 %)
#### safe_divide
- **Syntax:** `safe_divide(a, b)`
- **Description:** a / b, but NULL when b is 0. (KDB's `/` already gives NULL for a division by zero; this name is for programs written for BigQuery.)
- **Returns:** DOUBLE. **Example:** `SELECT safe_divide(sum_if(volume, close > open), sum(volume)) FROM candles`
#### div0
- **Syntax:** `div0(a, b)`
- **Description:** a / b, but 0 when b is 0 (Snowflake's `DIV0`).
- **Returns:** DOUBLE. **Example:** `SELECT div0(1, 0)` gives 0.
#### percent
- **Syntax:** `percent(a, b)`
- **Description:** a / b × 100.
- **Returns:** DOUBLE. **NULLs:** NULL when b is 0.
- **Example:** `SELECT symbol, percent(sum(volume), (SELECT sum(volume) FROM candles)) FROM candles GROUP BY symbol`
#### is_nan
- **Syntax:** `is_nan(x)`, `is_inf(x)`, `is_finite(x)`
- **Description:** tests for "not a number", infinity, and a normal finite number. KDB stores a NaN in a DOUBLE column as NULL, so these are mostly for values made in a query.
- **Returns:** BOOL. **NULLs:** NULL argument gives NULL.
- **Example:** `SELECT is_finite(1.5)`
#### random
- **Syntax:** `random()` (also `rand()`)
- **Description:** a random DOUBLE, 0 or more and less than 1. A new value for every call and every row. A query that uses it is never taken from the query result cache.
- **Returns:** DOUBLE. **Example:** a random sample of rows: `SELECT * FROM candles WHERE random() < 0.01`
#### setseed
- **Syntax:** `setseed(s)`
- **Description:** sets the start of `random()` so the numbers repeat, s from -1 to 1. It changes the random numbers for the whole server. With several query threads the order of rows can still change the numbers.
- **Returns:** NULL. **Example:** `SELECT setseed(0.42)`
#### normal_cdf
- **Syntax:** `normal_cdf(x [, mean [, sd]])`, `normal_pdf(x [, mean [, sd]])`; defaults 0 and 1
- **Description:** the normal distribution: the probability of a value ≤ x, and the density at x.
- **Returns:** DOUBLE. **NULLs:** error when sd ≤ 0.
- **Example:** `SELECT normal_cdf(-1.645)` gives about 0.05.
#### normal_inv
- **Syntax:** `normal_inv(p [, mean [, sd]])`
- **Description:** the inverse: x where the probability of a value ≤ x is p. Useful for a normal (parametric) value at risk: `-(avg(r) + normal_inv(0.05) * stddev(r))`.
- **Returns:** DOUBLE. **NULLs:** error when p is not between 0 and 1.
- **Example:** `SELECT normal_inv(0.975)` gives 1.959963984540054.
### Prices
| Name | Syntax | Meaning | Example | Result |
|---|---|---|---|---|
| `round_tick` | `round_tick(price [, tick = 0.1])` | nearest price on the tick grid | `round_tick(512.35)` | `512.4` |
| `floor_tick` | `floor_tick(price [, tick = 0.1])` | tick price at or below | `floor_tick(512.37)` | `512.3` |
| `ceil_tick` | `ceil_tick(price [, tick = 0.1])` | tick price at or above | `ceil_tick(512.31)` | `512.4` |
| `pivot_point` | `pivot_point(high, low, close [, method])` | pivot point P | `pivot_point(110, 90, 100)` | `100` |
| `pivot_level` | `pivot_level(high, low, close, level [, method])` | support and resistance levels | `pivot_level(110, 90, 100, 'r1')` | `110` |
#### round_tick
- **Syntax:** `round_tick(price [, tick])`, `floor_tick(price [, tick])`, `ceil_tick(price [, tick])`; tick default 0.1
- **Description:** puts a price on the tick grid (for example, prices that move in steps of 0.1, that is 10 cents). The result is rounded to the tick's decimals, so `round_tick(512.35)` is 512.4, not 512.4000000001. Half a tick goes up (away from zero).
- **Returns:** DOUBLE. **NULLs:** NULL argument gives NULL. Error when tick ≤ 0.
- **Example:** a 2 % target price on the grid: `SELECT symbol, ceil_tick(close * 1.02) FROM candles WHERE time = '2026-09-10'`
#### pivot_point
- **Syntax:** `pivot_point(high, low, close [, method])`
- **Description:** the pivot point P of a period. `'classic'` (default), `'fibonacci'` and `'camarilla'`: P = (H + L + C) / 3. `'woodie'`: P = (H + L + 2C) / 4. Use the **previous** period's high, low and close for today's levels (see the example of `pivot_level`).
- **Returns:** DOUBLE.
- **Example:** `SELECT pivot_point(high, low, close) FROM candles`
#### pivot_level
- **Syntax:** `pivot_level(high, low, close, level [, method])`
- **Description:** one pivot level. `level` is `'p'`, `'r1'`, `'r2'`, `'r3'`, `'s1'`, `'s2'`, `'s3'` (and `'r4'`, `'s4'` for Camarilla). With R = high - low:
| Method | R1 / S1 | R2 / S2 | R3 / S3 | R4 / S4 |
|---|---|---|---|---|
| classic (also `'standard'`) | 2P - L / 2P - H | P ± R | H + 2(P - L) / L - 2(H - P) | - |
| woodie | as classic, with Woodie's P | P ± R | as classic | - |
| fibonacci | P ± 0.382·R | P ± 0.618·R | P ± R | - |
| camarilla | C ± R·1.1/12 | C ± R·1.1/6 | C ± R·1.1/4 | C ± R·1.1/2 |
- **Returns:** DOUBLE. **NULLs:** NULL argument gives NULL. Error for an unknown level or method.
- **Example:** today's levels from yesterday's candle:
```sql
SELECT time, symbol, pivot_level(ph, pl, pc, 's1') AS s1, pivot_point(ph, pl, pc) AS p, pivot_level(ph, pl, pc, 'r1') AS r1
FROM (SELECT time, symbol,
lag(high) OVER (PARTITION BY symbol ORDER BY time) AS ph,
lag(low) OVER (PARTITION BY symbol ORDER BY time) AS pl,
lag(close) OVER (PARTITION BY symbol ORDER BY time) AS pc
FROM candles) x
WHERE time = '2026-09-10';
```
### Finance
| Name | Syntax | Meaning (Excel's function of the same name) |
|---|---|---|
| `cagr` | `cagr(start_value, end_value, years)` | compound yearly growth rate |
| `compound_interest` | `compound_interest(principal, rate, years [, times_per_year = 1])` | interest earned |
| `fv` | `fv(rate, nper, pmt [, pv = 0, type = 0])` | future value |
| `pv` | `pv(rate, nper, pmt [, fv = 0, type = 0])` | present value |
| `pmt` | `pmt(rate, nper, pv [, fv = 0, type = 0])` | payment per period |
| `nper` | `nper(rate, pmt, pv [, fv = 0, type = 0])` | number of periods |
| `npv` | `npv(rate, value1, value2, ...)` | net present value, first value one period away |
| `irr` | `irr(value0, value1, ...)` | internal rate of return per period |
Excel's sign rule: money you pay is negative, money you get is positive. `type` 0 = payments at the end of each period, 1 = at the start. `rate` is per period (`0.12 / 12` for 12 % a year paid monthly).
#### cagr
- **Syntax:** `cagr(start_value, end_value, years)`
- **Description:** (end / start)^(1 / years) - 1.
- **Returns:** DOUBLE. **NULLs:** NULL when start ≤ 0, end < 0 or years ≤ 0.
- **Example:** `SELECT cagr(100, 200, 5)` gives 0.1487 (14.87 % a year).
#### compound_interest
- **Syntax:** `compound_interest(principal, rate, years [, times_per_year])`
- **Description:** the interest earned: principal × ((1 + rate / times)^(times × years) - 1). Add the principal for the final amount.
- **Returns:** DOUBLE. **Example:** `SELECT compound_interest(100000, 0.08, 3, 4)` (8 % a year, paid quarterly, 3 years).
#### fv
- **Syntax:** `fv(rate, nper, pmt [, pv [, type]])`
- **Description:** future value of a series of equal payments (Excel `FV`).
- **Returns:** DOUBLE. **Example:** `SELECT fv(0.06 / 12, 10, -200, -500, 1)` gives 2581.40 (Excel's example).
#### pv
- **Syntax:** `pv(rate, nper, pmt [, fv [, type]])`
- **Description:** present value (Excel `PV`).
- **Returns:** DOUBLE. **Example:** `SELECT pv(0.08 / 12, 240, 500)` gives -59777.15.
#### pmt
- **Syntax:** `pmt(rate, nper, pv [, fv [, type]])`
- **Description:** payment per period of a loan (Excel `PMT`).
- **Returns:** DOUBLE. **Example:** `SELECT pmt(0.08 / 12, 10, 10000)` gives -1037.03 (Excel's example).
#### nper
- **Syntax:** `nper(rate, pmt, pv [, fv [, type]])`
- **Description:** number of periods (Excel `NPER`).
- **Returns:** DOUBLE. **NULLs:** NULL when there is no answer.
- **Example:** `SELECT nper(0.12 / 12, -100, -1000, 10000, 1)` gives 59.67.
#### npv
- **Syntax:** `npv(rate, value1, value2, ...)`
- **Description:** Σ value_i / (1 + rate)^i, i from 1 (Excel `NPV`: the first value is one period away). Up to 63 values. For values in rows with dates, use the aggregate `xnpv`.
- **Returns:** DOUBLE. **NULLs:** a NULL value gives NULL.
- **Example:** `SELECT npv(0.1, -10000, 3000, 4200, 6800)` gives 1188.44.
#### irr
- **Syntax:** `irr(value0, value1, ...)`
- **Description:** the rate per period where the net present value (value0 at time 0) is 0 (Excel `IRR`). Newton's method, then bisection.
- **Returns:** DOUBLE. **NULLs:** NULL when all values have the same sign or no rate is found.
- **Example:** `SELECT irr(-70000, 12000, 15000, 18000, 21000, 26000)` gives 0.0866.
### Dates for reports
All in **UTC** (for a local clock add its offset, for example `20700000` ms for Nepal, +05:45; chapter 4).
| Name | Syntax | Meaning | Example | Result |
|---|---|---|---|---|
| `extract`, `date_part` | `extract(field FROM t)`, `date_part('field', t)` | a part of a time | `extract(dow FROM '2026-09-11')` | `5` |
| `date_trunc` | `date_trunc('quarter', t)` | new unit `quarter` (chapter 11 has the others) | `date_trunc('quarter', '2026-08-15')` | `2026-07-01T00:00:00.000Z` |
| `datediff` | `datediff('month', a, b)` | new units `month` and `year` (whole calendar months) | `datediff('month', '2026-01-31', '2026-02-28')` | `1` |
| `age` | `age(a [, b])` | the time from b to a as text | `age('2026-09-11', '2020-01-05')` | `6 years 8 mons 6 days` |
| `quarter` | `quarter(t)` | 1 to 4 | `quarter('2026-09-11')` | `3` |
| `day_of_year` | `day_of_year(t)` | 1 to 366 | `day_of_year('2026-02-01')` | `32` |
| `week_of_year` | `week_of_year(t)` | ISO week, 1 to 53 | `week_of_year('2027-01-01')` | `53` |
| `last_day` | `last_day(t)` | last day of the month (DATE) | `last_day('2024-02-10')` | `2024-02-29` |
| `make_date` | `make_date(year, month, day)` | a DATE | `make_date(2026, 9, 11)` | `2026-09-11` |
| `make_timestamp` | `make_timestamp(y, m, d, h, mi, s)` | a TIMESTAMP (s may have decimals) | | |
| `fiscal_year` | `fiscal_year(t [, start_month = 7, start_day = 17])` | the year the fiscal year starts in | `fiscal_year('2026-07-16')` | `2025` |
| `fiscal_quarter` | `fiscal_quarter(t [, 7, 17])` | quarter of the fiscal year | `fiscal_quarter('2026-10-17')` | `2` |
| `nepal_fiscal_year` | `nepal_fiscal_year(t)` | Nepal's fiscal year in Bikram Sambat years | `nepal_fiscal_year('2026-09-11')` | `2083/84` |
#### extract
- **Syntax:** `extract(field FROM t)` or `date_part('field', t)`
- **Description:** fields: `year`, `quarter`, `month`, `week` (ISO), `isoyear`, `day`, `dow` (0 = Sunday ... 6 = Saturday), `isodow` (1 = Monday ... 7 = Sunday), `doy` (day of the year), `hour`, `minute`, `second` (with the milliseconds as decimals), `millisecond` (seconds × 1000 + ms, as PostgreSQL), `epoch` (seconds since 1970), `decade`, `century`.
- **Returns:** LONG; DOUBLE for `second` and `epoch`. **NULLs:** NULL argument gives NULL. Error for an unknown field.
- **Example:** `SELECT extract(month FROM time) AS m, sum(volume) FROM candles ORDER BY m`
#### age
- **Syntax:** `age(a, b)` or `age(b)`
- **Description:** the time from b to a in years, months, days and clock time, as text in PostgreSQL's style: `6 years 8 mons 6 days`, `1 mon 1 day`, `06:30:00`. With one argument, from b to today (midnight UTC). When b is after a, every part is negative.
- **Returns:** STRING. **NULLs:** NULL argument gives NULL.
- **Example:** `SELECT symbol, age(max(time), min(time)) FROM candles GROUP BY symbol`
#### quarter
- **Syntax:** `quarter(t)`
- **Description:** the calendar quarter, 1 (January to March) to 4.
- **Returns:** LONG. **Example:** `SELECT quarter(time) AS q, sum(volume) FROM candles`
#### day_of_year
- **Syntax:** `day_of_year(t)`
- **Description:** 1 for January 1, up to 366.
- **Returns:** LONG. **Example:** `SELECT day_of_year('2026-12-31')` gives 365.
#### week_of_year
- **Syntax:** `week_of_year(t)`
- **Description:** the ISO week number (weeks start on Monday; week 1 has the year's first Thursday). Early January days can be in week 52 or 53 of the year before.
- **Returns:** LONG. **Example:** `SELECT week_of_year('2027-01-01')` gives 53.
#### last_day
- **Syntax:** `last_day(t)`
- **Description:** the last day of t's month.
- **Returns:** DATE. **Example:** month-end rows: `SELECT * FROM candles WHERE to_date(time) = last_day(time)`
#### make_date
- **Syntax:** `make_date(year, month, day)`
- **Description:** a DATE from its parts.
- **Returns:** DATE. **NULLs:** error when the day does not exist (`make_date(2026, 2, 29)`).
- **Example:** `SELECT make_date(2026, 7, 17)`
#### make_timestamp
- **Syntax:** `make_timestamp(year, month, day, hour, minute, second)`
- **Description:** a TIMESTAMP (UTC) from its parts; second may have decimals.
- **Returns:** TIMESTAMP. **NULLs:** error for a day or time that does not exist.
- **Example:** `SELECT make_timestamp(2026, 9, 11, 5, 15, 0)` is 11:00 in Nepal time (+05:45).
#### fiscal_year
- **Syntax:** `fiscal_year(t [, start_month [, start_day]])`
- **Description:** the (Gregorian) year in which t's fiscal year starts. Nepal's fiscal year starts on Shrawan 1 of the Bikram Sambat calendar, which is July 16 or 17. KDB does not know the BS calendar: it uses a fixed start day, July 17 by default. In a year where Shrawan 1 is July 16, that one day is counted in the old year; pass `7, 16` for such a year. For a calendar year use `fiscal_year(t, 1, 1)`.
- **Returns:** LONG. **NULLs:** error when the start day is not 1 to 28.
- **Example:** `SELECT fiscal_year(time) AS fy, symbol, sum(volume) FROM candles`
#### fiscal_quarter
- **Syntax:** `fiscal_quarter(t [, start_month [, start_day]])`
- **Description:** 1 to 4: the quarter of the fiscal year. Quarters are 3 months from the start day (with the default: July 17 to October 16 is quarter 1).
- **Returns:** LONG. **Example:** `SELECT fiscal_quarter('2026-10-17')` gives 2.
#### nepal_fiscal_year
- **Syntax:** `nepal_fiscal_year(t)`
- **Description:** the fiscal year as company reports in Nepal write it, in Bikram Sambat years: `2083/84` for July 17, 2026 to July 16, 2027. It is `fiscal_year(t) + 57` and the next year, with the same start-day rule.
- **Returns:** STRING. **Example:** `SELECT nepal_fiscal_year(time) AS fy, sum(volume) FROM candles`
---
## Part 6: Tables of numbers and pivots
### generate_series and long_sequence
`generate_series` is a **table function**: write it in `FROM` like a table.
```sql
SELECT ... FROM generate_series(start, stop [, step]) [[AS] alias [(column)]]
SELECT ... FROM long_sequence(count)
```
| Form | Rows | Column type |
|---|---|---|
| `generate_series(1, 10)` | 1, 2, ..., 10 | LONG |
| `generate_series(10, 1, -3)` | 10, 7, 4, 1 | LONG |
| `generate_series(0, 1, 0.25)` | 0, 0.25, 0.5, 0.75, 1 | DOUBLE |
| `generate_series('2026-01-01'::TIMESTAMP, '2026-01-31'::TIMESTAMP, '1 day')` | every day | TIMESTAMP |
| `generate_series('2026-01-31'::TIMESTAMP, '2026-12-31'::TIMESTAMP, '1 month')` | month ends (Jan 31, Feb 28, ...) | TIMESTAMP |
| `long_sequence(5)` | x = 1, ..., 5 (QuestDB) | LONG |
Rules:
- The arguments must be constants (or `now()`, `dateadd(...)`, ...). They cannot use columns of other tables.
- Steps for times: `'1 day'`, `'6 hours'`, `'15 minutes'`, `'1 week'`, `'1 month'`, `'3 months'`, `'1 year'`, or a number of milliseconds. Month steps keep the day where they can (day clamped at month end). Without a step: 1 day. Two DATE values with a whole-day step give DATE rows.
- The column name: the name in `AS alias(column)`, else the alias, else `generate_series` (`x` for `long_sequence`).
- The last value is included when the steps reach it exactly. A step of 0 is an error. At most 10 million rows. A NULL argument gives no rows.
- A result is a normal source: join it, filter it, group it, use indicators on it.
#### generate_series
- **Syntax:** `generate_series(start, stop [, step])`
- **Description:** rows from start to stop, step by step. See the table above.
- **Returns:** a table with one column (LONG, DOUBLE, TIMESTAMP or DATE).
- **NULLs:** a NULL argument gives no rows.
- **Example:** every trading day of September 2026 and whether CITY has a candle on it:
```sql
SELECT d, count(c.close) AS candles
FROM generate_series('2026-09-01'::TIMESTAMP, '2026-09-10'::TIMESTAMP, '1 day') AS g(d)
LEFT JOIN candles c ON c.time = g.d AND c.symbol = 'CITY'
WHERE is_trading_day(d)
GROUP BY d ORDER BY d;
```
#### long_sequence
- **Syntax:** `long_sequence(count)`
- **Description:** rows x = 1 to count (the QuestDB function).
- **Returns:** a table with one LONG column `x`.
- **Example:** `SELECT x, x * x FROM long_sequence(5)`
### PIVOT
```sql
SELECT ... FROM source PIVOT (aggregate(x) [AS name], ... FOR column IN (value [AS name], ...)) [[AS] alias]
```
PIVOT turns values of one column into columns. Every column of the source that PIVOT does not use (not in the aggregates, not the FOR column) becomes a group key: one row per key. `source` is a table, a view, a subquery or a table function.
| Part | Rule |
|---|---|
| aggregates | any aggregate: `sum`, `count(*)`, `avg`, `max`, `last`, `corr`, ... |
| FOR column | a column or expression of the source |
| IN values | constants. A value without `AS` gives a column named after the value |
| column names | one aggregate: the value's name; several: `value_name`, like `jan_total` |
| missing combinations | NULL (`count` gives 0) |
It is the same as a GROUP BY with `CASE`: `sum(CASE WHEN month = 1 THEN turnover END) AS jan, ...`. PIVOT writes that for you. Put the columns you do not need out of the source with a subquery, or they become group keys.
#### PIVOT
- **Syntax:** see above.
- **Description:** one row per combination of the other columns, one column per value (and aggregate).
- **NULLs:** a combination with no rows gives NULL (0 for `count`). Rows where the FOR column is NULL match no value.
- **Example:** see [Monthly turnover by sector](#monthly-turnover-by-sector).
---
## Part 7: Cookbook
All recipes use the `candles` view from the start of this chapter.
### Top gainers per day
The change from the day before is a window over each symbol. The rank inside each day is a second window over the result, so it needs one more subquery level (`PARTITION BY time`).
03-cook-top-gainers.sql:
```sql
-- The two best symbols of each day, by the change from the day before.
SELECT time, symbol, chg_pct, place
FROM (
SELECT time, symbol, chg_pct, rank() OVER (PARTITION BY time ORDER BY chg_pct DESC) AS place
FROM (
SELECT time, symbol, round(pct_return(close) OVER (PARTITION BY symbol ORDER BY time) * 100, 2) AS chg_pct
FROM candles
) d
WHERE chg_pct IS NOT NULL
) r
WHERE place <= 2 AND time >= '2026-09-06'
ORDER BY time, place;
```
```text
time | symbol | chg_pct | place
-------------------------+--------+---------+------
2026-09-06T00:00:00.000Z | TELCO | 5.44 | 1
2026-09-06T00:00:00.000Z | CITY | 1.48 | 2
2026-09-07T00:00:00.000Z | TELCO | 1.33 | 1
2026-09-07T00:00:00.000Z | VOLT | 1.27 | 2
2026-09-08T00:00:00.000Z | VOLT | 1.73 | 1
2026-09-08T00:00:00.000Z | TELCO | 0.98 | 2
2026-09-09T00:00:00.000Z | VOLT | 1.94 | 1
2026-09-09T00:00:00.000Z | TELCO | 0.56 | 2
2026-09-10T00:00:00.000Z | VOLT | 2.13 | 1
2026-09-10T00:00:00.000Z | TELCO | 0.17 | 2
(10 rows, 5.8 ms)
```
### 20-day volatility
`volatility(close, 20, 240)` is the standard deviation of the last 20 daily log returns, scaled to a year of 240 trading days. The inner query computes it for every day; the outer one keeps the last day.
04-cook-volatility.sql:
```sql
-- Yearly volatility from the last 20 daily log returns (240 trading days a year).
SELECT symbol, round(close, 1) AS close, round(vol_20d * 100, 1) AS vol_pct
FROM (
SELECT time, symbol, close, volatility(close, 20, 240) OVER (PARTITION BY symbol ORDER BY time) AS vol_20d
FROM candles
) v
WHERE time = '2026-09-10'
ORDER BY vol_pct DESC;
```
```text
symbol | close | vol_pct
-------+--------+--------
CITY | 382.1 | 29.6
VOLT | 177.1 | 27.9
COAST | 299.1 | 26.8
TELCO | 1073.4 | 21.8
ACME | 312.2 | 21.2
(5 rows, 5.1 ms)
```
### Drawdown
`drawdown(close)` is the fall from the highest close so far. `max_drawdown` is the worst of it, and `arg_min(time, dd)` finds the day of the lowest point. Compute the drawdown on all days first, then filter the days to show.
05-cook-drawdown.sql:
```sql
-- The largest fall from a peak for every symbol, and the day of the lowest point.
SELECT symbol, round(max_drawdown(close) * 100, 1) AS max_dd_pct, arg_min(time, dd) AS worst_day
FROM (SELECT time, symbol, close, drawdown(close) OVER (PARTITION BY symbol ORDER BY time) AS dd FROM candles) x
GROUP BY symbol
ORDER BY max_dd_pct;
-- The drawdown of COAST on the last days.
SELECT time, close, round(drawdown(close) * 100, 2) AS dd_pct
FROM (SELECT time, close FROM candles WHERE symbol = 'COAST') n
WHERE time >= '2026-09-01';
```
```text
symbol | max_dd_pct | worst_day
-------+------------+-------------------------
CITY | -31.5 | 2026-07-05T00:00:00.000Z
COAST | -28.7 | 2026-09-10T00:00:00.000Z
VOLT | -20.9 | 2026-04-08T00:00:00.000Z
TELCO | -18.6 | 2026-08-12T00:00:00.000Z
ACME | -16.8 | 2026-05-21T00:00:00.000Z
(5 rows, 5.2 ms)
time | close | dd_pct
-------------------------+-------+-------
2026-09-01T00:00:00.000Z | 330.7 | 0
2026-09-02T00:00:00.000Z | 329.8 | -0.27
2026-09-03T00:00:00.000Z | 327.5 | -0.97
2026-09-06T00:00:00.000Z | 314.3 | -4.96
2026-09-07T00:00:00.000Z | 309.2 | -6.5
2026-09-08T00:00:00.000Z | 304.6 | -7.89
2026-09-09T00:00:00.000Z | 301.1 | -8.95
2026-09-10T00:00:00.000Z | 299.1 | -9.56
(8 rows, 4.4 ms)
```
### MACD crossover signals
The MACD histogram (line - signal) changes sign when the lines cross. `lag(hist)` in one more level gives yesterday's histogram. A cross from below is a buy signal, a cross from above a sell signal.
06-cook-macd-crossover.sql:
```sql
-- Days when the MACD line crosses its signal line.
SELECT time, symbol, round(line, 3) AS macd, round(sig, 3) AS signal,
CASE WHEN hist > 0 THEN 'BUY' ELSE 'SELL' END AS action
FROM (
SELECT time, symbol, line, sig, hist, lag(hist) OVER (PARTITION BY symbol ORDER BY time) AS prev_hist
FROM (
SELECT time, symbol,
macd(close) OVER (PARTITION BY symbol ORDER BY time) AS line,
macd_signal(close) OVER (PARTITION BY symbol ORDER BY time) AS sig,
macd_hist(close) OVER (PARTITION BY symbol ORDER BY time) AS hist
FROM candles
) m
) x
WHERE (hist > 0 AND prev_hist <= 0) OR (hist < 0 AND prev_hist >= 0)
ORDER BY time DESC, symbol
LIMIT 8;
```
```text
time | symbol | macd | signal | action
-------------------------+--------+---------+---------+-------
2026-09-07T00:00:00.000Z | VOLT | -3.953 | -4.085 | BUY
2026-09-02T00:00:00.000Z | VOLT | -4.098 | -4.059 | SELL
2026-08-27T00:00:00.000Z | VOLT | -4.054 | -4.171 | BUY
2026-08-24T00:00:00.000Z | CITY | 8.463 | 9.102 | SELL
2026-08-19T00:00:00.000Z | TELCO | -19.481 | -20.742 | BUY
2026-08-11T00:00:00.000Z | ACME | -4.911 | -5.082 | BUY
2026-08-05T00:00:00.000Z | COAST | 6.99 | 7.709 | SELL
2026-07-28T00:00:00.000Z | VOLT | 4.393 | 4.919 | SELL
(8 rows, 5.9 ms)
```
### Bollinger squeeze filter
A squeeze is a quiet market: the Bollinger band width is close to its lowest value of the last 120 days. Often a big move follows. `min(width) OVER (... ROWS 119 PRECEDING)` is the lowest width of 120 rows.
07-cook-bollinger-squeeze.sql:
```sql
-- A squeeze: the Bollinger band width is near its lowest of the last 120 days.
SELECT time, symbol, round(width * 100, 2) AS width_pct, round(lowest * 100, 2) AS lowest_120d_pct
FROM (
SELECT time, symbol, width, min(width) OVER (PARTITION BY symbol ORDER BY time ROWS 119 PRECEDING) AS lowest
FROM (SELECT time, symbol, bb_width(close, 20, 2) OVER (PARTITION BY symbol ORDER BY time) AS width FROM candles) b
) s
WHERE width <= lowest * 1.05 AND time >= '2026-08-20'
ORDER BY time, symbol;
```
```text
time | symbol | width_pct | lowest_120d_pct
-------------------------+--------+-----------+----------------
2026-08-24T00:00:00.000Z | CITY | 10.77 | 10.77
2026-08-25T00:00:00.000Z | CITY | 10.77 | 10.77
(2 rows, 5.6 ms)
```
### Correlation matrix of sectors
First the daily return of every sector (the average return of its symbols), then `corr` for every pair of sectors with a self-join, then `PIVOT` to turn the pairs into a matrix. 1 on the diagonal; values near 0 mean the sectors move independently.
08-cook-correlation-matrix.sql:
```sql
-- Daily return of each sector (the average of its symbols), then the correlation of every pair of sectors.
SELECT *
FROM (
SELECT a.sector AS sector, b.sector AS other, round(corr(a.r, b.r), 2) AS c
FROM (SELECT time, sector, avg(r) AS r
FROM (SELECT time, sector, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) x
GROUP BY time, sector) a
JOIN (SELECT time, sector, avg(r) AS r
FROM (SELECT time, sector, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) x
GROUP BY time, sector) b ON a.time = b.time
GROUP BY a.sector, b.sector
) pairs
PIVOT (max(c) FOR other IN ('Banking' AS banking, 'Energy' AS energy, 'Investment' AS investment, 'Telecom' AS telecom))
ORDER BY sector;
```
```text
sector | banking | energy | investment | telecom
-----------+---------+--------+------------+--------
Banking | 1 | -0.63 | 0.15 | -0.06
Energy | -0.63 | 1 | -0.59 | -0.18
Investment | 0.15 | -0.59 | 1 | -0.19
Telecom | -0.06 | -0.18 | -0.19 | 1
(4 rows, 12.4 ms)
```
### Monthly turnover by sector
Sectors as rows, months as columns. The subquery keeps only the three columns PIVOT needs; every other column would become a group key. The outer query rounds to millions.
09-cook-pivot-turnover.sql:
```sql
-- Turnover (close x volume) in millions: sectors as rows, months as columns.
SELECT sector, round(jun / 1000000, 1) AS jun, round(jul / 1000000, 1) AS jul, round(aug / 1000000, 1) AS aug, round(sep / 1000000, 1) AS sep
FROM (SELECT sector, month(time) AS m, close * volume AS turnover FROM candles WHERE time >= '2026-06-01') t
PIVOT (sum(turnover) FOR m IN (6 AS jun, 7 AS jul, 8 AS aug, 9 AS sep))
ORDER BY sector;
```
```text
sector | jun | jul | aug | sep
-----------+--------+--------+--------+------
Banking | 1213.6 | 1131.2 | 1198.2 | 392.5
Energy | 260.5 | 288.6 | 264.9 | 92.1
Investment | 431.3 | 434.7 | 445 | 168
Telecom | 1412.1 | 1563.6 | 1382.9 | 611.1
(4 rows, 5.3 ms)
```
### Histograms
`width_bucket(x, -4, 4, 8)` puts each daily return into a 1 % bucket from -4 % to +4 %. Bucket 0 means below -4 %, bucket 9 means +4 % or more. Items that are not aggregates are the group keys.
10-cook-histogram.sql:
```sql
-- How often did CITY move by how much? Buckets of 1 % from -4 % to +4 %.
SELECT bucket, -5 + bucket AS from_pct, count(*) AS days
FROM (SELECT width_bucket(pct_return(close) * 100, -4, 4, 8) AS bucket FROM candles WHERE symbol = 'CITY') h
WHERE bucket IS NOT NULL
ORDER BY bucket;
```
```text
bucket | from_pct | days
-------+----------+-----
0 | -5 | 5
1 | -4 | 5
2 | -3 | 16
3 | -2 | 24
4 | -1 | 46
5 | 0 | 42
6 | 1 | 25
7 | 2 | 8
8 | 3 | 4
9 | 4 | 5
(10 rows, 5.1 ms)
```
### Beta against a benchmark
The sample data has no market index, so the "market" here is the average daily return of the five symbols. Join each symbol's returns to the market returns by day, then use `beta`, `alpha`, `information_ratio` and `corr` with `GROUP BY`. With a real index table, join to its returns instead.
11-cook-beta.sql:
```sql
-- Beta, alpha and information ratio of each symbol against a "market" made of all five symbols.
SELECT s.symbol, round(beta(s.r, m.r), 2) AS beta, round(alpha(s.r, m.r) * 240 * 100, 2) AS alpha_pct_year,
round(information_ratio(s.r, m.r, 240), 2) AS info_ratio, round(corr(s.r, m.r), 2) AS corr
FROM (SELECT time, symbol, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) s
JOIN (SELECT time, avg(r) AS r
FROM (SELECT time, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) x
GROUP BY time) m ON s.time = m.time
GROUP BY s.symbol
ORDER BY beta DESC;
```
```text
symbol | beta | alpha_pct_year | info_ratio | corr
-------+-------+----------------+------------+------
CITY | 2.12 | -25.23 | -1.06 | 0.45
COAST | 1.9 | -35.58 | -1.51 | 0.43
TELCO | 1.87 | 32.33 | 1.27 | 0.45
ACME | 1.2 | 39.01 | 1.65 | 0.31
VOLT | -2.08 | -10.52 | -0.04 | -0.49
(5 rows, 12.0 ms)
```
### Risk report
All risk aggregates work on daily returns: make them in a subquery, then group by symbol.
12-cook-risk.sql:
```sql
-- Risk report per symbol, from daily returns.
SELECT symbol, count(r) AS days,
round(sharpe_ratio(r, 0, 240), 2) AS sharpe,
round(sortino_ratio(r, 0, 240), 2) AS sortino,
round(calmar_ratio(r, 240), 2) AS calmar,
round(value_at_risk(r, 0.95) * 100, 2) AS var95_pct,
round(expected_shortfall(r, 0.95) * 100, 2) AS es95_pct,
round(skewness(r), 2) AS skew
FROM (SELECT time, symbol, pct_return(close) OVER (PARTITION BY symbol ORDER BY time) AS r FROM candles) x
GROUP BY symbol
ORDER BY sharpe DESC;
```
```text
symbol | days | sharpe | sortino | calmar | var95_pct | es95_pct | skew
-------+------+--------+---------+--------+-----------+----------+------
ACME | 180 | 1.45 | 2.31 | 2.28 | 1.91 | 3.34 | 0.31
TELCO | 180 | 1.05 | 1.59 | 1.42 | 1.88 | 3.7 | 0.16
VOLT | 180 | -0.16 | -0.23 | -0.35 | 1.98 | 4.1 | -0.01
CITY | 180 | -1.08 | -1.46 | -0.96 | 3.25 | 4.44 | 0.05
COAST | 180 | -1.5 | -1.98 | -1.26 | 2.67 | 4.43 | 0.01
(5 rows, 6.3 ms)
```
### Many indicators at once
Indicators need the history before the days you show: compute them on all rows inside, filter the days outside (`trend` is `supertrend_direction`: 1 up, -1 down).
13-cook-indicators.sql:
```sql
-- Many indicators on the last days of one symbol.
SELECT time, close, rsi, round(stoch_k, 1) AS stoch_k, round(adx, 1) AS adx, round(sar, 1) AS sar, trend, round(bb_pb, 2) AS bb_pb
FROM (
SELECT time, close,
round(rsi(close, 14), 1) AS rsi,
stoch_k(high, low, close) AS stoch_k,
adx(high, low, close, 14) AS adx,
sar(high, low) AS sar,
supertrend_direction(high, low, close, 10, 3) AS trend,
bb_percent_b(close, 20, 2) AS bb_pb
FROM candles WHERE symbol = 'VOLT'
) i
WHERE time >= '2026-09-01';
```
```text
time | close | rsi | stoch_k | adx | sar | trend | bb_pb
-------------------------+-------+------+---------+------+-------+-------+------
2026-09-01T00:00:00.000Z | 166.3 | 35.3 | 40.9 | 25.2 | 174.6 | -1 | 0.31
2026-09-02T00:00:00.000Z | 164.8 | 33.5 | 37.3 | 25.7 | 174.4 | -1 | 0.29
2026-09-03T00:00:00.000Z | 163.7 | 32.2 | 31.1 | 26.2 | 174 | -1 | 0.26
2026-09-06T00:00:00.000Z | 165.1 | 35.7 | 28.6 | 26.4 | 173.3 | -1 | 0.33
2026-09-07T00:00:00.000Z | 167.2 | 40.6 | 33.4 | 25.8 | 172.6 | -1 | 0.44
2026-09-08T00:00:00.000Z | 170.1 | 46.6 | 47 | 24.2 | 172 | -1 | 0.63
2026-09-09T00:00:00.000Z | 173.4 | 52.5 | 63.6 | 23.2 | 162.6 | -1 | 0.87
2026-09-10T00:00:00.000Z | 177.1 | 58.1 | 79.3 | 22.7 | 162.9 | 1 | 1.06
(8 rows, 5.0 ms)
```
### Pivot levels on the tick grid
Today's pivot levels come from yesterday's high, low and close (`lag`). `round_tick` puts them on a 0.1 price grid (10 cents).
14-cook-pivots-ticks.sql:
```sql
-- Today's pivot levels from yesterday's candle, on the price tick grid.
SELECT symbol,
round_tick(pivot_level(ph, pl, pc, 's1')) AS s1,
round_tick(pivot_point(ph, pl, pc)) AS p,
round_tick(pivot_level(ph, pl, pc, 'r1')) AS r1,
round_tick(pivot_level(ph, pl, pc, 'r1', 'camarilla')) AS cam_r1
FROM (SELECT time, symbol,
lag(high) OVER (PARTITION BY symbol ORDER BY time) AS ph,
lag(low) OVER (PARTITION BY symbol ORDER BY time) AS pl,
lag(close) OVER (PARTITION BY symbol ORDER BY time) AS pc
FROM candles) x
WHERE time = '2026-09-10'
ORDER BY symbol;
```
```text
symbol | s1 | p | r1 | cam_r1
-------+--------+--------+--------+-------
ACME | 312.3 | 314.5 | 316.9 | 315.2
CITY | 385.5 | 387.9 | 390.9 | 389
COAST | 298.9 | 301.4 | 303.6 | 301.5
TELCO | 1063.8 | 1072.2 | 1079.9 | 1073.1
VOLT | 172.1 | 173.9 | 175.2 | 173.7
(5 rows, 5.4 ms)
```
### Nepal fiscal year
`nepal_fiscal_year` and `fiscal_quarter` group rows by a fiscal year that starts in mid-July, as in Nepal (see the note in [fiscal_year](#fiscal-year)).
15-cook-fiscal-year.sql:
```sql
-- Volume per Nepal fiscal year and fiscal quarter.
SELECT nepal_fiscal_year(time) AS fy, fiscal_quarter(time) AS q, count(*) AS rows_, sum(volume) AS volume
FROM candles
ORDER BY fy, q;
SELECT extract(year FROM '2026-09-11') AS y, extract(quarter FROM '2026-09-11') AS q, week_of_year('2026-09-11') AS week,
age('2026-09-11', '2020-01-05') AS age, datediff('month', '2026-01-31', '2026-02-28') AS months;
```
```text
fy | q | rows_ | volume
--------+---+-------+---------
2082/83 | 2 | 55 | 3827816
2082/83 | 3 | 325 | 22483660
2082/83 | 4 | 325 | 22400713
2083/84 | 1 | 200 | 13842460
(4 rows, 8.3 ms)
y | q | week | age | months
-----+---+------+-----------------------+-------
2026 | 3 | 37 | 6 years 8 mons 6 days | 1
(1 row, 0.1 ms)
```
### Calendars with generate_series
`generate_series` makes rows of numbers or days. A LEFT JOIN to it shows the days without data.
16-cook-generate-series.sql:
```sql
SELECT count(*) AS n, sum(generate_series) AS total FROM generate_series(1, 100);
SELECT m, day(m) AS day FROM generate_series('2026-01-31'::TIMESTAMP, '2026-05-31'::TIMESTAMP, '1 month') AS s(m);
-- Days without a candle (Fridays and Saturdays) in the first week of September.
SELECT d, day_of_week(d) AS dow, count(c.close) AS candles
FROM generate_series('2026-09-01'::TIMESTAMP, '2026-09-07'::TIMESTAMP, '1 day') AS g(d)
LEFT JOIN candles c ON c.time = g.d AND c.symbol = 'CITY'
GROUP BY d
ORDER BY d;
```
```text
n | total
----+------
100 | 5050
(1 row, 0.2 ms)
m | day
-------------------------+----
2026-01-31T00:00:00.000Z | 31
2026-02-28T00:00:00.000Z | 28
2026-03-31T00:00:00.000Z | 31
2026-04-30T00:00:00.000Z | 30
2026-05-31T00:00:00.000Z | 31
(5 rows, 0.1 ms)
d | dow | candles
-------------------------+-----+--------
2026-09-01T00:00:00.000Z | 2 | 1
2026-09-02T00:00:00.000Z | 3 | 1
2026-09-03T00:00:00.000Z | 4 | 1
2026-09-04T00:00:00.000Z | 5 | 0
2026-09-05T00:00:00.000Z | 6 | 0
2026-09-06T00:00:00.000Z | 0 | 1
2026-09-07T00:00:00.000Z | 1 | 1
(7 rows, 4.9 ms)
```
### Loans and savings
A 5-year loan of $500,000 at 10 % a year paid monthly (EMI), a monthly saving of $5000 for 10 years at 8 %, the yearly growth of a share from $400 to $510 in 3 years, and the IRR of a bond-like investment.
17-cook-finance.sql:
```sql
SELECT round(pmt(0.10 / 12, 60, 500000), 2) AS emi_5y_loan,
round(fv(0.08 / 12, 120, -5000), 2) AS sip_10y,
round(cagr(400, 510, 3) * 100, 2) AS cagr_pct,
round(irr(-100000, 8000, 8000, 8000, 120000) * 100, 2) AS irr_pct;
```
```text
emi_5y_loan | sip_10y | cagr_pct | irr_pct
------------+-----------+----------+--------
-10623.52 | 914730.18 | 8.44 | 10.56
(1 row, 0.3 ms)
```
---
### Speed
- The new aggregates, window frames with `BETWEEN`, and all functions of this chapter run in the **row engine**. A `GROUP BY` query with only `count`, `sum`, `avg`, `min`, `max`, `first` and `last` on plain columns uses the faster column engine; add `corr(...)` and the whole query uses the row engine. On the sample data this does not matter; on tens of millions of rows it can be several times slower.
- Indicators over windows (WMA, Bollinger, Donchian, CCI, linear regression, `zscore(x, n)`, ...) do about n steps per row: `bb_upper(close, 200)` over a million rows is about 200 million steps.
- Aggregates that keep all values (`percentile_cont`, `percentile_disc`, `mode`, `mad`, `iqr`, `value_at_risk`, `expected_shortfall`, `string_agg`, `array_agg`) sort the values again for every row when used with a running `OVER (... ORDER BY ...)` frame. Use `ROWS n PRECEDING` for moving versions over long partitions.
### Try it yourself
1. Which symbol had the biggest drawdown in the sample candles? Use `max_drawdown`.
2. Write the 10-day correlation between CITY's and COAST's daily returns for every day.
3. How many trading days does each month of 2026 have? Use `generate_series` and `is_trading_day`.
Answers
1. `SELECT symbol, max_drawdown(close) AS dd FROM candles GROUP BY symbol ORDER BY dd LIMIT 1`
2. `SELECT time, corr(a, b) OVER (ORDER BY time ROWS 9 PRECEDING) FROM (SELECT n.time AS time, n.r AS a, i.r AS b FROM (SELECT time, pct_return(close) AS r FROM candles WHERE symbol = 'CITY') n JOIN (SELECT time, pct_return(close) AS r FROM candles WHERE symbol = 'COAST') i ON n.time = i.time) x`
3. `SELECT month(d) AS m, count_if(is_trading_day(d)) AS days FROM generate_series('2026-01-01'::TIMESTAMP, '2026-12-31'::TIMESTAMP, '1 day') AS g(d) ORDER BY m`
---
Next: [20. Charts and dashboards](20-charts-and-dashboards.md)
---
# 20. Charts and dashboards
- Category: Tutorial
### What you will learn
- How to see any query result as a chart: candles, lines, bars, pie, treemap, heatmaps, numbers and more.
- How KDB picks a chart for you, and how to change it with one click.
- How to add indicators (SMA, EMA, Bollinger, RSI, MACD, stochastic) to candles.
- How to zoom, pan and read values with the mouse, a touch screen or the keyboard.
- How to build **dashboards**: many charts on one page, with pickers like `$symbol`, auto refresh, save, share and import.
Everything runs in the web console (chapter 6). No internet is needed. The charts are drawn by KDB's own code: plain JavaScript files in the console folder (`web/charts` next to `kdb`, `web/console/charts` in the source). No chart library is loaded from the internet.
### Before you start
Load the sample data (see the quick start) and start the server:
```powershell
kdb serve demo
```
Open . The examples in this chapter use the tables `ochl_day`, `ochl_minutes`, `live_data` and `securities` of the sample data.
At the top left of the console is the **Home** menu. **Home** is the SQL page you know from chapter 6. **Dashboard** is new. The button shows where you are, for example `Home / Dashboard`. The console remembers which page you used last.
## Part 1: Charts from a query
### Your first chart
1. Choose **Home** in the **Home** menu at the top left.
2. Type this query and press **Ctrl+Enter**:
```sql
SELECT time, open, high, low, close, volume FROM ochl_day WHERE symbol = 'CITY' ORDER BY time;
```
3. Click **Chart** above the result.
> **Picture:** a dark chart area. Green and red candles go from left to right. Under them is a smaller area with green and red volume bars. Prices are on the right side, dates at the bottom. At the top left a line shows the date, O, H, L, C, the change in % and the volume of the last candle.
4. Move the mouse over the candles. A dashed crosshair follows the mouse. The top line shows the candle under the mouse. The price and the date are marked on the axes.
5. Turn the mouse wheel to zoom. Drag to move left and right. Double-click to go back.
Above the chart there is a **toolbar**:
| Part | What it does |
|---|---|
| Chart type list | Shows the chart type (here **Candlestick**). Choose another type here. |
| **Columns** | Choose which columns the chart uses. |
| Symbol list | Only when the result has several symbols: the chart shows one symbol at a time. |
| **Indicators** | Add SMA, EMA, Bollinger bands, RSI, MACD or stochastic. |
| **Log** | Logarithmic price scale. Good for long periods with big moves. |
| **No gaps** | Leave out time without rows (nights, Friday and Saturday, holidays). Every candle gets the same width. |
| Legend | Names and colors of the lines. Click a name to hide or show that line. |
| **Reset** | Back to the first view (like double-click). |
| **PNG** | Save the chart as a picture. |
| **CSV** | Save the rows of the chart as a CSV file (no new query). |
| **Full screen** | Show only the chart. Press **Escape** to go back. |
| **Add to dashboard** | Put this query and this chart on a dashboard (Part 2). |
The console remembers the chart type and the columns for each query. Run the same query again and you get the same chart.
### How KDB picks the chart
KDB looks at the columns of the result and picks a chart. You can always change it.
| The result has | KDB shows | Example query |
|---|---|---|
| a time column and `open`, `high`, `low`, `close` | **Candlestick** (volume bars when there is a `volume` column) | `SELECT time, open, high, low, close, volume FROM ochl_day WHERE symbol = 'CITY'` |
| a time column and numbers | **Line** | `SELECT time, close FROM ochl_day WHERE symbol = 'CITY'` |
| a time column, a symbol column (up to 8 symbols) and a number | **Line**, one line per symbol | `SELECT time, symbol, close FROM ochl_day` |
| a time column, many symbols (more than 8) and one number | **Matrix heatmap** (symbols down, days across) | `SELECT time, symbol, pct_change FROM ...` |
| a text column and a number | **Bar** (horizontal bars when the names are long or many) | `SELECT symbol, sum(volume) AS volume FROM ochl_day GROUP BY symbol` |
| a text column and a share that adds up to 100 (or 1) | **Donut** | `SELECT symbol, weight FROM my_portfolio` |
| two text columns like sector and symbol, and numbers | **Treemap** | see [Treemap](#treemap) |
| two label columns and a number, like a correlation table | **Matrix heatmap** | see [Matrix heatmap](#matrix-heatmap) |
| the same text many times and a number | **Box plot** (one box per text) | `SELECT symbol, (close - open) / open * 100 AS change_pct FROM ochl_day` |
| one number column | **Histogram** | `SELECT (close - open) / open * 100 AS change_pct FROM ochl_day` |
| two number columns | **Scatter** | `SELECT close, volume FROM ochl_day` |
| three number columns | **Bubble** (the third number is the size) | |
| one row of numbers | **KPI number** | `SELECT count(*) AS trades, sum(amount) AS amount FROM trade_log` |
| no number column | no chart: the **Chart** button is off | `SELECT symbol, name FROM securities` |
### All chart types
Open the chart type list to see them all. Types that do not fit the result are grey, with a short note of what they need (for example `Candlestick (needs a time column and open, high, low, close)`).
| Group | Types |
|---|---|
| Price | Candlestick, OHLC bars |
| Time | Line, Step line, Area, Stacked area |
| Compare | Bar (vertical), Bar (horizontal), Grouped bar, Stacked bar |
| Spread | Histogram, Box plot, Scatter, Bubble |
| Share | Pie, Donut, Treemap |
| Heatmap | Calendar heatmap, Matrix heatmap |
| Number | KPI number, Gauge |
| Table | Table (green and red change columns, color scales, sparklines) |
Colors: green and red always mean up and down. Lines and bars use eight other colors (blue, orange, violet, pink, yellow, cyan, brown, purple) that people with color blindness can also tell apart. The charts follow the **Light / Dark** button of the console.
### Choose the columns
Click **Columns**. A small box opens. What you can choose depends on the chart type:
| Chart type | Choices |
|---|---|
| Candlestick, OHLC | Time, Open, High, Low, Close, Volume, One chart per (for example `symbol`) |
| Line, Step, Area, Stacked area | Time (X), Values (Y, several), Series (group), One chart per |
| Bars | Category, Values (several), Series (group), Colors (one color per series, or green above 0 and red below) |
| Histogram | Value, Bins (how many bars; empty = chosen for you) |
| Box plot | Group, Value |
| Scatter, Bubble | X, Y, Size, Color, Label |
| Pie, Donut | Category, Value |
| Treemap | Group, Label, Size, Color (change) |
| Calendar heatmap | Date, Value, One chart per |
| Matrix heatmap | Columns (X), Rows (Y), Value; or Value columns (wide table) |
| KPI number | Numbers, Change column, Order by time, Minimum, Maximum |
| Gauge | Value, Order by time, Minimum, Maximum |
| Table | Green/red columns, Color scale columns, Sparkline: one row per, time, value |
Press **Escape** or **Close** to close the box.
### Candles, volume and indicators
#### Indicators from the menu
1. Run the candle query from [Your first chart](#your-first-chart) and click **Chart**.
2. Click **Indicators**.
3. Choose **RSI** in the list and click **Add**. A new area opens under the volume bars. It shows the RSI line with lines at 30 and 70.
4. Choose **MACD** and click **Add**. Another area shows the MACD line, the signal line and green/red histogram bars.
5. Choose **SMA** and click **Add**. A line is drawn over the candles.
6. Change the numbers in the box, for example `period 50` for SMA. The chart changes at once.
7. Click **Remove** to take an indicator away.
| Indicator | Where | Settings |
|---|---|---|
| SMA, EMA | over the candles | period (20) |
| Bollinger bands | over the candles, with a light band | period (20), mult (2) |
| RSI | own area, 0 to 100, lines at 30 and 70 | period (14) |
| MACD | own area: MACD, signal, histogram | fast (12), slow (26), signal (9) |
| Stochastic | own area, 0 to 100, lines at 20 and 80 | period (14), smooth (3), signal (3) |
The indicators are computed in the browser from the close prices of the rows. They also work on a line chart with one line.
At the top left of each area, a small line shows the values under the mouse, for example `RSI RSI 14 56.21`.
#### Indicators from SQL
Chapter 19 has many indicator functions. When your query returns them as columns, the chart puts them in the right place by their name:
```sql
SELECT time, open, high, low, close, volume,
sma(close, 5) AS sma_5,
rsi(close, 5) AS rsi_5
FROM ochl_day WHERE symbol = 'CITY' ORDER BY time;
```
| Column name contains | Drawn |
|---|---|
| `sma`, `ema`, `wma`, `vwap`, `bb_upper`, `bb_middle`, `bb_lower`, `upper`, `lower` | a line over the candles |
| `rsi` | the RSI area (0 to 100) |
| `macd`, `signal`, `hist` | the MACD area; a name with `hist` is drawn as bars |
| `stoch`, `_k`, `_d` | the stochastic area |
| `atr`, `adx`, `cci`, `obv`, `mfi`, `roc`, `willr` | an own area with that name |
| any other number | over the candles when its values are near the prices, else an own area |
A name without `AS` works too, for example the column `sma(close, 20)`.
#### Big data: 100,000 candles
The chart stays fast with many rows. When you zoom out so far that several candles fall on one pixel, KDB joins them: one candle then stands for 2, 4, 8, ... rows (first open, highest high, lowest low, last close, total volume). The top line then shows the time range, for example `2026-01-04 - 2026-02-11 O 505 H 530 L 498 C 521`. Zoom in and you see every candle again.
For more than 100,000 rows, raise **Rows** at the top of the editor. The most is 1,000,000 rows per query.
### Zoom, pan and read values
| Mouse or touch | What it does |
|---|---|
| Wheel | Zoom in and out around the mouse |
| Drag | Move left and right (scatter: in every direction) |
| Two fingers (phone, tablet) | Pinch to zoom, move to pan |
| Tap (phone) | Show the values at that place |
| Double-click | Reset |
| Move the mouse | Crosshair and values (candles and lines: top line; other charts: a small box next to the mouse) |
The chart can be used with the keyboard. Press **Tab** until the chart has a blue frame:
| Keys | What it does |
|---|---|
| **Left** / **Right** (or **Up** / **Down**) | Move a cursor from row to row, bar to bar, slice to slice. The values are shown and read out by screen readers. |
| **Shift** + arrow | Move the view |
| **+** / **-** | Zoom in / out |
| **Home** or **0** | Reset |
| **F** | Full screen |
| **Escape** | Hide the cursor, or leave full screen |
#### Log scale
Tick **Log** for a logarithmic price scale. The same percent move then has the same height at a low price and at a high price. Log works when all visible values are above 0. It is not offered for bars.
#### No gaps (trading-day time axis)
A stock market is open only some hours of some days (for example 11:00 to 15:00, Sunday to Thursday, as on some exchanges). With real time, a minute chart has big empty spaces for nights and weekends. With **No gaps**, those spaces are left out and each row gets the same width. The date labels are then placed on the first row of a new day, month or year.
- Candles and OHLC use **No gaps** from the start.
- Lines use it when the rows have big holes. Untick **No gaps** to see real time.
### Tables with colors and sparklines
Choose **Table** in the chart type list.
- Columns whose names mean a change (`change`, `pct_change`, `diff`, `gain`, `return`, ...) and columns with plus and minus values are **green** with ▲ when above 0 and **red** with ▼ when below. The arrows help people who cannot see colors well.
- In **Columns**, pick **Color scale columns** to color the cells from low (light) to high (dark). With plus and minus values the scale goes red - grey - green.
- Click a column name to sort.
**Sparklines:** when the result has a symbol, a time and a price, the table shows one row per symbol with the last price, the change in % and a small price line:
```sql
SELECT symbol, time, close FROM ochl_day ORDER BY time;
```
> **Picture:** a table with the columns `symbol`, `close`, `change %` and `trend`. Each row has a small line in the `trend` column. The line is green when the price went up, red when it went down.
A cell with a JSON list of numbers, like `[505.2, 507.1, 503.9]`, also becomes a sparkline.
The table shows at most 2,000 rows. The **Grid** view shows all rows.
### Examples for each chart type
All examples use the sample data. Run the query, click **Chart**, and choose the type when KDB picks another one.
#### Line, step line, area
```sql
SELECT time, symbol, close FROM ochl_day ORDER BY time;
```
One line per symbol, with a legend. Choose **Step line** for prices that jump, or **Area** for one line with a light fill.
#### Stacked area
```sql
SELECT o.time, o.symbol, o.close * h.qty AS value
FROM ochl_day o
JOIN (SELECT 'CITY' AS symbol, 100 AS qty UNION ALL SELECT 'COAST', 150) h ON h.symbol = o.symbol
ORDER BY o.time;
```
Choose **Stacked area**. The layers add up to the total value of the two holdings.
#### Bars
```sql
SELECT symbol, sum(volume) AS volume FROM ochl_day GROUP BY symbol ORDER BY volume DESC;
```
> **Picture:** three blue bars with rounded tops, one per symbol, with the value on top of each bar.
For long names KDB turns the bars sideways:
```sql
SELECT s.name, sum(o.volume) AS volume FROM ochl_day o JOIN securities s ON s.symbol = o.symbol GROUP BY s.name;
```
#### Grouped and stacked bars
```sql
SELECT time, symbol, volume FROM ochl_day WHERE time >= '2026-08-23' ORDER BY time;
```
Choose **Stacked bar**: one bar per day, one colored part per symbol. Choose **Grouped bar** to put the symbols side by side. In **Columns**, **Category** is `time`, **Values** is `volume` and **Series** is `symbol`.
Bars with plus and minus values: open **Columns** and choose **Colors: Green above 0, red below**.
#### Histogram
```sql
SELECT (close - open) / open * 100 AS change_pct FROM ochl_day;
```
How many days moved by how much. Hover over a bar to see the range and the number of days. Set **Bins** in **Columns** for more or fewer bars.
#### Box plot
```sql
SELECT symbol, (close - open) / open * 100 AS change_pct FROM ochl_day;
```
One box per symbol. The box goes from the 25% value to the 75% value; the thick line is the middle value (median). The thin lines reach the lowest and the highest normal values. Dots are unusual values (outliers). Hover over a box to see all numbers.
#### Scatter and bubble
```sql
SELECT close, volume FROM ochl_day;
```
One dot per row. Hover over a dot to see its values. For a bubble chart, return three numbers and choose **Bubble**; the third number is the size of the bubble. A text column in **Color** gives each group its own color and a legend.
#### Pie and donut
```sql
SELECT s.sector, sum(l.ltp * l.volume) AS turnover
FROM (SELECT symbol, ltp, volume FROM live_data LATEST ON timestamp PARTITION BY symbol) l
JOIN securities s ON s.symbol = l.symbol
GROUP BY s.sector;
```
Choose **Donut**. The middle shows the total. The legend shows the share of each sector in %. More than 8 parts are joined into **Other**.
#### Treemap
```sql
SELECT s.sector, l.symbol, l.ltp * l.volume AS turnover, l.pct_change
FROM (SELECT symbol, ltp, volume, pct_change FROM live_data LATEST ON timestamp PARTITION BY symbol) l
JOIN securities s ON s.symbol = l.symbol;
```
> **Picture:** big rectangles, one per sector, with the sector name on top. Inside, one box per symbol. A bigger box means more turnover. Green boxes went up, red boxes went down; the stronger the color, the bigger the move. Each box shows the symbol and the change, for example `CITY +2.71%`.
#### Calendar heatmap
```sql
SELECT time, (close - open) / open * 100 AS change_pct FROM ochl_day WHERE symbol = 'CITY';
```
Choose **Calendar heatmap**. One small square per day, in columns of weeks (a week starts on Sunday) and rows of weekdays. Green days went up, red days went down. When the days span less than about a year, the calendar shows only those weeks; otherwise one row per year.
#### Matrix heatmap
Daily moves of every symbol:
```sql
SELECT time, symbol, (close - open) / open * 100 AS change_pct FROM ochl_day ORDER BY time;
```
Choose **Matrix heatmap**. Symbols go down, days go across.
A correlation matrix (with the `corr` function of chapter 19):
```sql
SELECT a.symbol AS a, b.symbol AS b, corr(a.close, b.close) AS corr
FROM ochl_day a JOIN ochl_day b ON a.time = b.time
GROUP BY a.symbol, b.symbol;
```
KDB picks the matrix by itself. Values from -1 to 1 get red (-1), grey (0) and green (1), and each cell shows its number when there is room.
#### KPI number and gauge
```sql
SELECT count(*) AS trades, sum(amount) AS amount FROM trade_log;
```
> **Picture:** two big numbers side by side, with the column names above them.
With several rows in time order, the KPI shows the last value, the change from the row before with ▲ or ▼, and a small line of all values:
```sql
SELECT time, close FROM ochl_day WHERE symbol = 'CITY' ORDER BY time;
```
Choose **KPI number**. For a **Gauge** (a half circle that fills up), set **Minimum** and **Maximum** in **Columns**. Without them the gauge uses 0 to a round number above the value (0 to 100 for names with `pct`, `rsi` or `share`).
## Part 2: Dashboards
A dashboard is a page with many charts. Each chart is a **panel**. A panel has a title, one SQL query and chart settings. Dashboards are saved **in the database** (table `_dashboards`), so they are in every backup.
### Open the example dashboards
Choose **Dashboard** in the **Home** menu at the top left. The first time, the list at the top left has three examples:
| Example | Panels |
|---|---|
| **Market overview** | Market turnover (KPI), advancers and decliners (KPI), market average close (area), top gainers and top losers (tables with green and red), sectors (treemap), turnover by symbol (bars). Refreshes every 5 seconds. |
| **Stock view** | Candles with volume, SMA 20, RSI 14 and MACD; last close (KPI); daily move % (calendar); size of daily moves (histogram). Pickers for symbol, from and to. |
| **Portfolio** | Portfolio value (area), allocation (donut), value by symbol (stacked area), holdings with trend (table with sparklines), a note. |
> **Picture (Market overview):** a row of three boxes at the top: a big number `126,481,156` for turnover, the numbers `3` and `1` for advancers and decliners, and a small area chart. Below are two tables with green ▲ and red ▼ values, then a treemap of sectors and horizontal bars of turnover.
The examples use the tables of the sample data. On your own database, open one and change the SQL of the panels (see [Edit a panel](#edit-a-panel)). Click **Save** to keep your own copy; the example itself does not change.
### Make your own dashboard
1. Open **Dashboard** in the **Home** menu, then **More** > **New dashboard**.
2. Type a title, for example `My watchlist`, and click **Create**. The empty dashboard opens in edit mode.
3. Click **Variables**, then **Add variable**. Fill in:
- name: `symbol`
- label: `Symbol`
- type: **Values from SQL**
- default value: `CITY`
- SQL for the values: `SELECT DISTINCT symbol FROM ochl_day ORDER BY symbol`
Click **OK**. A **Symbol** list appears under the title bar.
4. Click **Add panel**. Fill in:
- Title: `Candles`
- SQL:
```sql
SELECT time, open, high, low, close, volume FROM ochl_day
WHERE symbol = $symbol ORDER BY time
```
Click **Run preview** (or press **Ctrl+Enter** in the SQL box). The chart appears on the right. Change the chart type or the columns there, if you want. Click **Add**.
5. Click **Add panel** again:
- Title: `Volume share`
- SQL: `SELECT symbol, sum(volume) AS volume FROM ochl_day GROUP BY symbol`
- **Run preview**, choose **Donut** in the chart type list, click **Add**.
6. Click **Save**. The message says `Saved "My watchlist" in the table _dashboards of database demo.`
7. Click **Done** to leave edit mode.
8. Choose another symbol in the **Symbol** list. Every panel that uses `$symbol` runs again.
Reload the page: the Dashboards page and your dashboard open again.
### Add a chart from the SQL page
1. Run a query on the **Home** page and click **Chart**.
2. Pick the chart type and the columns.
3. Click **Add to dashboard** in the chart toolbar.
4. Choose the dashboard (or **A new dashboard**) and a panel title. Click **Add**.
5. The dashboard opens with the new panel. Click **Save**.
### Edit a panel
Each panel has a **...** button at its top right:
| Menu item | What it does |
|---|---|
| **Edit** | Change the title, the SQL, the number of rows, the chart type and the columns |
| **Refresh** | Run this panel again |
| **Open SQL in editor** | Put the SQL (with the variable values filled in) into the SQL page |
| **Save PNG**, **Save CSV** | Save the chart as a picture, or its rows as CSV |
| **Full screen** | Show only this chart |
| **Reset zoom** | Back to the first view |
| **Duplicate** | A copy of the panel |
| **Remove** | Delete the panel (edit mode only) |
In the panel editor, **Kind** can also be **Text note**: a panel with plain text, for example how to use the dashboard.
A panel runs **one** statement that **reads**: `SELECT` (also `WITH`, `SHOW`, `EXPLAIN`). The console sends it with `GET /exec`, and the server refuses SQL that changes data there (chapter 8). So a dashboard can never change or delete data, even when someone sends you a bad dashboard file.
On a dashboard, the mouse wheel scrolls the page. To zoom a chart, hold **Ctrl** and turn the wheel, or click the chart first.
### Move and resize panels
Click **Edit**. The panels get a grip (three lines) at the top left and a corner at the bottom right.
- **Move:** drag the top bar of a panel. A dashed box shows where it will go. Other panels move down to make room.
- **Resize:** drag the corner at the bottom right.
- **With the keyboard:** press **Tab** until the grip of a panel is selected. The **arrow keys** move the panel; **Shift + arrow keys** make it wider, narrower, taller or shorter.
The page has 12 columns. Panels move up when there is space above them, so there are no holes.
On a phone (narrow screen), the panels are shown one under the other, in the order top to bottom and left to right.
### Variables
A variable is a picker at the top of the dashboard. Write `$name` (or `${name}`) in the SQL of a panel.
| Type | Picker | Put into SQL as | Example |
|---|---|---|---|
| **Text** | a text box | a quoted text: `'CITY'` | `WHERE symbol = $symbol` |
| **List of values** | a list with the values you typed (`CITY, COAST, VOLT`) | `'COAST'` | `WHERE symbol = $symbol` |
| **Values from SQL** | a list with the first column of a query | `'COAST'` | `SELECT DISTINCT symbol FROM ochl_day ORDER BY symbol` |
| **Number** | a number box | `25` (only a number is allowed) | `LIMIT $top` |
| **Date** | a date box | `'2026-09-11'` | `WHERE time BETWEEN $from AND $to` |
| **Table or column name** | a text box | the name, only letters, digits and `_` | `SELECT * FROM $table` |
| **Interval** | a text box | `1d`, `15m`, `1M` | `SAMPLE BY $step` |
- **Many values:** tick **many values** for a list. Then write `IN ($symbols)`: `WHERE symbol IN ($symbols)` becomes `WHERE symbol IN ('CITY', 'COAST')`.
- **Dates relative to today:** a default of `today`, `today-30d`, `today-1w`, `today-3M` or `today-1y` is turned into a date when the dashboard opens. `now-2h` gives a time.
- A `$name` inside quotes (`'$symbol'`) or in a comment is not changed.
- The values you pick are remembered in your browser for each dashboard.
**Why this is safe:** text values are always written as SQL text with quotes, and a quote inside the value is doubled (`O'Brien` becomes `'O''Brien'`). A number must be a number, a date must look like a date, and a name may only have letters, digits and `_`. A wrong value shows an error in the panel and nothing runs. So a value like `CITY'; DROP TABLE x; --` stays harmless text.
### Auto refresh
The **Auto refresh** list has **Off**, **5 s**, **10 s**, **30 s**, **1 min**, **5 min** and **15 min**. With 5 s, every panel runs again every 5 seconds, for example for live prices from `live_data`.
- Auto refresh pauses while the browser tab is hidden and while a dialog is open.
- When you zoomed a chart, the zoom stays after a refresh. When the view showed the newest rows, it follows the new rows.
- The refresh interval is saved with the dashboard. **Refresh** runs all panels at once.
- The server keeps a result cache (`kdb serve --cache-mb`, default 64 MB). A panel that asks the same question again, while the tables did not change, gets its answer from memory. So auto refresh is cheap.
### Save, share, export and import
**Save** writes the dashboard into the table `_dashboards` of the chosen database. The console makes the table the first time:
```sql
CREATE TABLE IF NOT EXISTS _dashboards (id STRING PRIMARY KEY, title STRING NOT NULL, body STRING NOT NULL, updated TIMESTAMP, updated_by STRING);
```
You can look at it on the SQL page:
```sql
SELECT id, title, updated, updated_by FROM _dashboards;
```
```text
id | title | updated | updated_by
--------------+--------------+--------------------------+-----------
my-watchlist | My watchlist | 2026-09-11T08:15:02.113Z | null
```
(`updated_by` is the user name when the server has users.)
Each database has its own dashboards. Choose another database at the top and the list changes. `BACKUP DATABASE` and **Backup now** save the dashboards too.
The **More** menu:
| Item | What it does |
|---|---|
| **New dashboard** | An empty dashboard |
| **Duplicate** | A copy of this dashboard (Save to keep it) |
| **Export JSON file** | Download the dashboard as a `.json` file |
| **Copy JSON** | Copy the same text to the clipboard, to paste in a chat or email |
| **Import JSON** | Paste JSON or choose a `.json` file. The dashboard opens; click **Save** to keep it. An import never replaces a saved dashboard. |
| **Delete** | Delete the saved dashboard from the database |
A dashboard file looks like this (shortened):
```json
{
"kdb_dashboard": 1,
"id": "my-watchlist",
"title": "My watchlist",
"description": "",
"refresh": 0,
"variables": [
{ "name": "symbol", "label": "Symbol", "type": "query", "default": "CITY",
"sql": "SELECT DISTINCT symbol FROM ochl_day ORDER BY symbol" }
],
"panels": [
{ "id": "candles", "kind": "chart", "title": "Candles", "x": 0, "y": 0, "w": 6, "h": 5,
"sql": "SELECT time, open, high, low, close, volume FROM ochl_day\nWHERE symbol = $symbol ORDER BY time",
"chart": { "type": "candle", "indicators": [{ "kind": "rsi", "period": 14 }] },
"limit": 10000 }
]
}
```
- `x`, `y`, `w`, `h`: place and size in grid units (12 columns; one row is 60 pixels).
- `chart`: the chart type and the columns, like in the **Columns** box.
- When a file is imported, KDB checks it: unknown fields are dropped, wrong values are fixed, and panels never overlap.
### Who can do what
When the server has users (chapter 16), the role decides:
| Role on the database | Look at dashboards | Change them on the page | Save | Delete |
|---|---|---|---|---|
| `read` | yes | yes (not saved; use **Export JSON file**) | no, the **Save** button is off | no |
| `write` | yes | yes | yes, when the table `_dashboards` exists | yes |
| `admin` | yes | yes | yes; the first save creates `_dashboards` | yes |
The first dashboard in a database creates the table `_dashboards`. Creating a table needs the `admin` role. If a `write` user saves first, the message says: `The first dashboard in a database creates the table _dashboards. That needs the admin role; ...` Ask an admin to save one dashboard, or to run the `CREATE TABLE` above.
Panels run with the role of the person who looks at the dashboard. A `read` user sees only data that `read` may see.
### Limits
- A panel shows at most the rows set in **Rows at most** (up to 100,000). The SQL page can show up to 1,000,000 rows.
- Up to 60 panels and 20 variables per dashboard.
- A **Table** chart shows 2,000 rows (the Grid view shows all).
- Pie and donut show 8 parts at most; the rest is **Other**. Scatter colors: 8 groups at most.
- Indicators from the **Indicators** menu need one series (no **Series** column).
- Log scale is for line, candle, area and scatter charts, not for bars.
- Dashboards are shared with everyone who can open the database. There are no private dashboards; use a separate database for that.
- There is no "undo" in the dashboard editor. Leave without saving to go back (the console asks first).
### Try it yourself
1. Show the daily moves of CITY as a calendar heatmap. Which day had the biggest drop?
2. Make a chart of the CITY minute candles for 15 minutes, with Bollinger bands and a stochastic area.
3. Make a dashboard **Two stocks** with two variables `$a` and `$b` (lists of symbols) and a line panel that shows both closes.
4. A friend sends you a dashboard file. How do you open it without replacing your own dashboards?
Answers
1. Run
```sql
SELECT time, (close - open) / open * 100 AS change_pct FROM ochl_day WHERE symbol = 'CITY';
```
Click **Chart**, choose **Calendar heatmap**, and move the mouse over the reddest square. The box next to the mouse shows the date and the value.
2. Run
```sql
SELECT time, first(open) AS open, max(high) AS high, min(low) AS low, last(close) AS close, sum(volume) AS volume
FROM ochl_minutes WHERE symbol = 'CITY' SAMPLE BY 15m;
```
Click **Chart**, then **Indicators**: add **Bollinger bands** and **Stochastic**.
3. **More** > **New dashboard**, title `Two stocks`. **Variables**: add `a` and `b`, type **List of values**, values `CITY, COAST, VOLT`, defaults `CITY` and `COAST`. **Add panel** with
```sql
SELECT time, symbol, close FROM ochl_day WHERE symbol IN ($a, $b) ORDER BY time
```
KDB draws one line per symbol. **Save**.
4. **More** > **Import JSON**, choose the file, **Open**. Check the SQL of the panels, then **Save**. An import always gets a new id, so it never replaces a saved dashboard.
---
Back to the tutorial overview
---
# 21. Protection
- Category: Tutorial
## What you will learn
KDB already protects your data from crashes and disk damage (chapter 9) and from strangers (chapter 16). This chapter is about the other dangers of a real app:
- **Part A.** A slow or huge query that blocks the server: timeouts, cancel, memory limits, statements per user, and a stop that lets running requests finish.
- **Part B.** "Who changed this?": the audit log.
- **Part C.** Backups that happen by themselves, with a checked copy on another disk.
- **Part D.** Rights on single tables: an app user who may add ledger rows but never change them.
- **Part E.** Stronger logins: a password rule and two-factor codes.
- **Part F.** Mistakes: `UPDATE` without `WHERE`, a read-only database, `DROP TABLE` by mistake.
- **Part G.** What is not there yet: encryption of the files.
- A **VPS checklist** and **what it costs** in speed.
Everything here is fast. A running query looks at one flag every 16,384 rows (never at every row). Tables without audit pay nothing. The numbers are at the end.
## The short version
For the NChartPro server on a VPS, start KDB like this:
```sh
kdb serve --data-dir /srv/kdb/data --backups /srv/kdb/backups \
--backup-schedule "daily 16:00" --backup-copy /mnt/backup-disk/kdb \
--statement-timeout 30s --max-queries-per-user 32 --safe-updates
```
Then, in the console as an admin:
```sql
ALTER TABLE ledger SET AUDIT ON;
ALTER TABLE accounts SET AUDIT ON;
REVOKE UPDATE, DELETE ON TABLE ledger FROM app;
ALTER USER admin ENABLE TOTP;
```
The rest of the chapter explains each line.
| Protection | Default in `kdb serve` | How to change |
|---|---|---|
| statement timeout | 60 s | `--statement-timeout 30s`, `SET statement_timeout = '5s'` |
| memory of one query | 1 GB of rows, 5,000,000 groups | `--max-result-memory`, `--max-groups` |
| statements per user | 32 at the same time | `--max-queries-per-user` |
| requests at a stop | may finish for 30 s; new ones get 503 | `--drain-timeout 5s` |
| audit log of logins, users, DDL, backups | on (with a data folder) | `--audit off`, `--audit-keep-days N` |
| audit of changed rows | off | `ALTER TABLE t SET AUDIT ON` |
| scheduled backups | off | `--backup-schedule "daily 16:00"`, `--backup-copy ` |
| table rights | none | `GRANT/REVOKE ... ON TABLE` |
| password rule | at least 12 characters, not the user name | always on |
| two-factor login | off | `ALTER USER x ENABLE TOTP` |
| safe updates | off | `--safe-updates`, `SET safe_updates = on` |
| read-only | off | `--read-only`, `ALTER DATABASE x SET READ ONLY` |
| DROP TABLE trash | 7 days (also `kdb sql` and `kdb shell`) | `--trash-days N` |
`kdb sql`, `kdb shell` and apps that use the KDB library directly have no timeouts or limits (they are your own programs). Their `DROP TABLE` keeps the trash.
---
## Part A: slow and huge queries
### Statement timeout
A statement that runs longer than the limit is stopped. In the web console of `kdb serve market` (with the sample data of chapter 2):
```sql
SET statement_timeout = '2s';
SELECT count(*) FROM ochl_minutes a CROSS JOIN ochl_minutes b CROSS JOIN ochl_minutes c WHERE a.close + b.close > c.close;
```
```text
statement_timeout is 2 s for this session
error: canceling statement due to statement timeout (2 s); make the query faster (WHERE, an index, LIMIT) or raise statement_timeout
```
- The limit counts from the start of the request (over HTTP) or of the query message (PostgreSQL).
- **Nothing of a stopped change is written.** An `UPDATE` stopped halfway changes no row; inside `BEGIN ... COMMIT` the transaction is rolled back, like any failed statement.
- The server's limit is `--statement-timeout` (60 s by default; `0` = no limit). A user who is not an admin can only make it shorter.
- HTTP: send `X-KDB-Statement-Timeout: 5s` with a request, or `SET statement_timeout = '5s'` in an [HTTP session](08-http-api.md).
- PostgreSQL: `SET statement_timeout = 5000` (milliseconds) or `'5s'`, like PostgreSQL itself. The error has SQLSTATE `57014`.
- HTTP answers a stopped statement with status **408**.
### Cancel a running statement
| From | How |
|---|---|
| Go `pgx`, Python, psql | cancel the context / press Ctrl+C: the driver sends a PostgreSQL CancelRequest, KDB stops the statement (`57014`, "due to user request") |
| HTTP | send `X-KDB-Request-Id: ` with `POST /exec`; `DELETE /exec/` stops it |
| HTTP | close the connection: KDB notices it within 0.2 s and stops the statement |
```sh
# terminal 1: a long report
curl -s -H "X-KDB-Request-Id: report-17" --data "SELECT ... big report ..." http://127.0.0.1:9900/exec
# terminal 2: stop it
curl -s -X DELETE http://127.0.0.1:9900/exec/report-17
```
```text
{"request_id":"report-17","cancelled":true}
```
Users may stop only their own requests; admins may stop all.
### Memory of one query
A query that keeps too many rows in memory (the result, rows waiting for `ORDER BY`, `DISTINCT`, window functions) or makes too many groups is stopped before it uses all the memory of the VPS:
```text
error: the query keeps too many rows in memory (about 1030 MB; the limit is 1024 MB, max_result_memory). Add a WHERE or a LIMIT, or fetch the rows in parts with a cursor
error: the query makes more than 5000000 groups (max_groups). Group by fewer columns, add a WHERE, or use a longer SAMPLE BY interval
```
Set them with `--max-result-memory 512MB` and `--max-groups 1000000`. HTTP status 413, SQLSTATE `54000`. For a small VPS (1-2 GB), use `--max-result-memory 256MB`.
A big `GROUP BY` is not stopped just because its groups need a lot of memory. When they need more than half of `--max-result-memory`, KDB writes the groups that do not fit to temporary files in the database folder (`_tmp/`) and adds them up after the scan. The result is the same, only slower. The files are removed when the query ends. One query may write at most 16 GB of them; past that it stops with `the GROUP BY needs more than N MB of temporary files (max_spill_bytes)`.
### Statements per user
`--max-queries-per-user 32`: one user runs at most 32 statements at the same time (HTTP and PostgreSQL together). More get HTTP 429 (`user 'app' already runs 32 statements at the same time`). This stops one broken client or one script in a loop from taking every thread. Your app's connection pool should be smaller than the limit (pgx: `pool_max_conns=16`).
### Stopping the server
Ctrl+C, SIGTERM and `docker stop` do not cut running requests off:
| Request | What happens |
|---|---|
| already running | finishes and gets its real answer, for up to `--drain-timeout` (30 s; 5 s in the Docker image) |
| arrives during the stop | HTTP **503** with `Retry-After: 2`; nothing of it is run. `/health` answers 503 too. |
| still running after `--drain-timeout` | cancelled like a statement timeout; nothing half-done is written |
Keep the drain time below how long your service manager waits before it kills the process (`docker run --stop-timeout 60`, `TimeoutStopSec=` in systemd). Clients should send a request again after a 503: a cut-off `/write` or `/exec` was rolled back as a whole.
---
## Part B: the audit log
### Security events
`kdb serve --data-dir ...` writes these events to a table `_audit_log` in the hidden `_system` database:
| event | When |
|---|---|
| `login`, `login_failed`, `lockout` | a login (HTTP, Basic, PostgreSQL), a wrong password or code, an address that must wait |
| `user` | `CREATE/ALTER/DROP USER`, `GRANT`, `REVOKE`, also `kdb user ...` while the server is stopped |
| `ddl` | `CREATE`, `DROP`, `ALTER`, `RENAME` of tables, indexes, views, functions, procedures, triggers; `CREATE/DROP DATABASE` |
| `protection` | `SET AUDIT`, `UNDROP TABLE`, `ALTER DATABASE ... SET READ ONLY` |
| `backup` | every scheduled or manual backup, OK or failed |
| `denied` | a statement the user's rights did not allow |
| `server` | the server started or stopped |
Read them as a server admin:
```sql
SHOW AUDIT WHERE event = 'login_failed' LIMIT 20;
SHOW AUDIT WHERE user = 'app' AND time > dateadd('d', -1, now());
```
```text
time | event | user | client | db | object | detail | ...
-------------------------+--------------+-------+--------------------+----+--------+------------------------------------------+
2026-09-12T10:31:07.412Z | login_failed | admin | 203.0.113.7 (http) | | admin | wrong password; 3 failure(s) in a row |
```
Over HTTP: `GET /api/audit?limit=50&where=event%20%3D%20'lockout'`. The `where` can use every column, but no subqueries of other tables.
Nobody can change the audit rows: `DELETE FROM _audit_log` answers `the audit table _audit_log is append-only`. The rows are written in groups every 200 ms, through the log like any change: a crash can lose only the events of that last moment.
Old rows: `--audit-keep-days 400` removes whole months older than 400 days, once a day. The default keeps everything. For accounting data, keep the log as long as the law asks.
A server started on one folder by its path (`kdb serve D:\kdb\market`) has no `_system` and writes no security events. Use `--data-dir`.
### Who changed this row?
For money tables, turn on the data-change audit:
```sql
ALTER TABLE accounts SET AUDIT ON;
UPDATE accounts SET balance = balance - 25 WHERE id = 1;
SELECT time, event, user, row_key, old_values, new_values, detail FROM _audit_log ORDER BY time DESC LIMIT 5;
```
```text
audit is on for table 'accounts': every changed row is written to _audit_log
1 row(s) updated
time | event | user | row_key | old_values | new_values | detail
-------------------------+--------+------+---------+------------------------------------------+-----------------------------------------+------------------------------------------------
2026-09-12T10:40:02.118Z | update | app | id='1' | {"id":1,"owner":"Sita","balance":100.00} | {"id":1,"owner":"Sita","balance":75.00} | UPDATE accounts SET balance = balance - 25 ...
```
- Every row that an `INSERT`, `UPDATE`, `DELETE`, `INSERT OR REPLACE`, `CALL`, trigger or line-protocol request adds, changes or deletes gets one audit row: `event` (insert, update, delete), `user`, `client`, `session`, the statement, the row key, and the row before and after as JSON.
- The audit row is written **in the same transaction** as the change. There is never a change without its audit row, not even after a power cut. A rolled back transaction leaves neither.
- Columns whose name holds `password`, `secret`, `token`, `scram` or `totp` show `"***"`.
- An `UPDATE` that changes nothing in a row writes no audit row.
- Only admins of the database can read `_audit_log`; nobody can change it.
- Tables without `SET AUDIT ON` cost nothing extra. An audited table's writes cost about one row lookup and one extra row per changed row.
- `ALTER TABLE accounts SET AUDIT OFF` stops it; the rows already written stay.
---
## Part C: backups on a schedule
Chapter 9 made backups by hand. `kdb serve` can do it by itself:
```sh
kdb serve --data-dir D:\kdb\data --backups D:\kdb_backups --backup-schedule "daily 16:00" --backup-copy E:\kdb_copy
```
```text
schedule backups daily 16:00, next 2026-09-12 16:00:00, checked copy in E:\kdb_copy
...
2026-09-12 16:00:03 backup of 'market' OK: 41 files, 312.4 MB, 2850 ms, D:/kdb_backups/market/20260912-101500-004; copy checked: E:/kdb_copy/market/20260912-101500-004
2026-09-12 16:00:03 backup of '_system' OK: 6 files, 1.2 MB, 90 ms, ...
```
| Schedule | Meaning |
|---|---|
| `daily 16:00` | every day at 16:00 local time (for example after a market closes at 15:00) |
| `daily 02:00,16:00` | twice a day |
| `hourly`, `hourly :30` | every hour |
| `every 6h`, `every 30m` | from midnight on |
- Times are the **local time** of the computer. In a zone without daylight saving (such as Nepal, +05:45), `16:00` stays 16:00 all year. On a VPS set to UTC, write the UTC time: `daily 10:15` for 16:00 in Nepal.
- Every database of the data folder and `_system` (users and audit log) are backed up. Each backup is read back and checked, as in chapter 9.
- **The backup does not stop the server.** It waits for an open transaction to end (at most a few seconds), writes a checkpoint, then copies the files while reads and writes go on. The test `test_protection` writes rows during a scheduled backup to check this.
- `--backup-copy `: after each backup, a second copy in another folder, best on another disk. KDB checks every block and log checksum of the copy by reading it back from that disk. A failed copy is reported (`COPY FAILED`) and the backup itself still counts.
- Old backups go by `--keep-daily 7 --keep-weekly 4`, in both folders.
- `--backup-warn-hours 26`: when a database's newest good backup is older, the log says `WARNING: the last good backup of 'x' is 30 hours old ...` once an hour. Watch for it (`journalctl -u kdb | grep WARNING`).
- The console's health panel shows the schedule, the next backup and a `(!)` when the last one failed. `/api/info` has the full status in `backup_schedule`.
---
## Part D: rights on tables
The roles of chapter 16 give rights on a whole database. Table rights change that for one table:
```sql
CREATE USER app WITH PASSWORD 'App-password-long-42';
GRANT write ON DATABASE shop TO app;
REVOKE UPDATE, DELETE ON TABLE ledger FROM app; -- the ledger only grows
CREATE USER auditor WITH PASSWORD 'Auditor-password-17';
GRANT SELECT ON TABLE ledger TO auditor; -- reads one table, nothing else
SHOW USERS;
```
```text
name | role | grants | created | changed | table_grants | two_factor
--------+-------+------------+---------+---------+----------------------------------+-----------
app | none | shop=write | ... | ... | shop.ledger: -UPDATE -DELETE | false
auditor | none | NULL | ... | ... | shop.ledger: +SELECT | false
```
Now `app` gets `DELETE on table 'ledger' was revoked for user 'app' (REVOKE DELETE ON TABLE ledger)` (HTTP 403, SQLSTATE `42501`), and `auditor` can read `ledger` but gets 403 for every other table, also inside subqueries and joins.
| Statement | Does |
|---|---|
| `GRANT SELECT, INSERT, UPDATE, DELETE, ALL ON TABLE t TO u` | adds rights on one table of the current database |
| `REVOKE ... ON TABLE t FROM u` | takes rights away on that table, even when the role has them |
| `GRANT DEFAULT ON TABLE t TO u` | the role decides again for that table |
- Rights are checked **once per statement**, before it runs (and before the result cache). There is no cost per row.
- They count for `/exec`, `/exp`, `/cursor`, the line protocol (`INSERT`) and the PostgreSQL port.
- `DROP TABLE` removes the table rights of that table; `RENAME TABLE` moves them. A new table with an old name does not get old rights.
- `CALL` and triggers run their statements without table rights (only admins create procedures and triggers).
- A view is a table for rights: `GRANT SELECT ON TABLE cheap_prices TO u` lets `u` read what the view shows.
- **`REVOKE ON DATABASE`** takes away exactly that grant. `REVOKE read ON DATABASE shop FROM app` does **not** remove a `write` grant; it says `user 'app' keeps role write ... Nothing was revoked`. Use `REVOKE write` or `REVOKE ALL`.
---
## Part E: stronger logins
### The password rule
A new password of a database user must have **at least 12 characters** (letters of any language count as one each) and must not be the user name. Old passwords keep working. For programs, use 20 or more random characters.
### Two-factor login
For people who use the console (especially admins):
```sql
ALTER USER admin ENABLE TOTP;
```
```text
user | otpauth_uri | secret
------+---------------------------------------------------------------------------------------------+---------------------------------
admin | otpauth://totp/KDB:admin?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=KDB&algorithm=SHA1&... | JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP
two-factor login is on for user 'admin': add the otpauth URI (or the secret) to an authenticator app now; it is shown only once
```
Put the secret into Google Authenticator, Microsoft Authenticator, Aegis or 1Password **now** (it is shown only once). From then on:
- The console's login box asks for the **6-digit code** after a right password.
- Programs send it to `POST /api/login` as `{"user": "admin", "password": "...", "code": "492039"}`.
- A code works once. Wrong codes count as failed logins (after 5, the address must wait).
- HTTP Basic and the PostgreSQL port cannot ask for a code, so they refuse this user. Programs use their own users without two-factor login.
- `ALTER USER admin DISABLE TOTP` turns it off. A lost phone: stop the server and run `kdb user reset-2fa admin`.
### Other login protection
- **Lockouts** (5 failures for a user from one address) are in the audit log as `lockout`.
- **A new token for every login.** When a login request still carries an old login (cookie or Bearer token), the old one ends. A token somebody planted in a browser before the login is worthless afterwards.
---
## Part F: mistakes
### UPDATE or DELETE without WHERE
```sh
kdb serve ... --safe-updates
```
```sql
UPDATE accounts SET balance = 0;
```
```text
error: safe updates are on: an UPDATE without WHERE would change every row of 'accounts'. Add a WHERE (WHERE true changes every row on purpose), or turn safe updates off for this session: SET safe_updates = off
```
Each session can switch: `SET safe_updates = off` (or on) in SQL, `X-KDB-Safe-Updates: off` over HTTP, `SET safe_updates = off` on the PostgreSQL port.
### A read-only database
```sql
ALTER DATABASE market_2025 SET READ ONLY;
```
Now every change of `market_2025` is refused (`the database is read-only (...)`, HTTP 403, SQLSTATE `25006`); reads, backups and checks work. The setting is kept in the data folder and holds after a restart. `ALTER DATABASE market_2025 SET READ WRITE` undoes it. A read-only database cannot be dropped.
`kdb serve --read-only` makes the whole server read-only: all databases, no new databases, no user changes. Use it for a report server that works on a copy of the data.
### DROP TABLE by mistake
`DROP TABLE` does not remove the rows at once. The table goes into the **trash** for 7 days:
```sql
DROP TABLE ledger;
SHOW DROPPED TABLES;
UNDROP TABLE ledger;
```
```text
table 'ledger' dropped; UNDROP TABLE ledger brings it back within 7 days
name | dropped | removed_after | rows | stored_as
-------+--------------------------+--------------------------+-------+------------------------------
ledger | 2026-09-12T10:50:12.004Z | 2026-09-19T10:50:12.004Z | 48210 | _dropped_1789217412004_ledger
table 'ledger' is back, with 48210 row(s)
```
- Dropping takes no time: nothing is copied. A new table and indexes with the same names can be made at once (app migrations keep working).
- `UNDROP TABLE` brings back the newest table of that name with its rows and indexes (not its table rights).
- After `--trash-days` (7) the server removes it for good. `DROP TABLE _dropped_..._ledger` removes one at once.
- A table with named `UNIQUE` or `PRIMARY KEY` constraints (PostgreSQL style) is dropped for good at once for now.
- `DROP DATABASE` already moved whole databases to `data/_trash` (chapter 1); that stays.
---
## Part G: encryption of the files
Not built yet. Today whoever can read the data folder or a backup can read the data: protect them with file permissions (chapter 16) and keep backups private. The plan (AES-256-GCM per block with AES-NI, a key file or the OS key store, the log and backups encrypted too, key rotation, about 3-8% slower cold reads and no change for queries on loaded data) is in docs/design/encryption-at-rest.md. It comes after the versioning work, because it changes the bytes on disk.
---
## VPS checklist
Add this to the checklist of chapter 16:
| Check | Why |
|---|---|
| `--backup-schedule "daily 10:15"` (16:00 in Nepal, +05:45, on a UTC VPS) and `--backup-copy` on a second disk or mounted storage | backups happen even when you forget |
| `journalctl -u kdb | grep -E "WARNING|FAILED"` in a daily mail or monitor | a broken backup is noticed |
| `--statement-timeout 30s`, `--max-result-memory 256MB` on a 1-2 GB VPS | one report cannot freeze the app |
| `--max-queries-per-user` above your app's pool size | a loop in a script cannot take every thread |
| `ALTER TABLE ... SET AUDIT ON` for accounts, ledger, orders, portfolio | every money change has who and when |
| `REVOKE UPDATE, DELETE ON TABLE ledger FROM app` | even a bug in the app cannot rewrite history |
| `ALTER USER ENABLE TOTP` for every person | a stolen password is not enough |
| `--safe-updates` | no `UPDATE` without `WHERE` by accident |
| `SHOW AUDIT WHERE event IN ('lockout', 'denied')` now and then | see who tries |
| old years: `ALTER DATABASE ... SET READ ONLY` | closed books stay closed |
Example `systemd` line:
```ini
ExecStart=/usr/local/bin/kdb serve --data-dir /srv/kdb/data --backups /srv/kdb/backups --backup-schedule "daily 10:15" --backup-copy /mnt/backup/kdb --statement-timeout 30s --max-result-memory 256MB --safe-updates --audit-keep-days 2000
```
---
## What it costs
Measured on the development PC (Intel i9-14900HX, Windows 11), same data, runs mixed so that other work on the PC hits both sides. "On" is `kdb serve` with its defaults (60 s timeout, memory and group limits, 32 statements per user); "off" is the same program with `--statement-timeout 0 --max-result-memory 0 --max-groups 0 --max-queries-per-user 0`.
| Test | Off (or before) | On (after) |
|---|---|---|
| line protocol, 8 clients x 200,000 rows | before: 2.21, 2.13, 1.96 million rows/s | 2.21, 2.26, 2.16 million rows/s |
| `SELECT count(*), sum(volume)` over 1.1 million rows, no result cache (server time, 3 runs) | 153 µs | 154 µs |
| `GROUP BY symbol`, no cache | 1,384 µs | 1,306 µs |
| `LATEST ON`, no cache | 394 µs | 385 µs |
| 5,000 rows to JSON, no cache | 1,605 µs | 1,593 µs |
| `SELECT count(*)+sum`, with the result cache (round trip, 1,000 runs, 3 times) | 0.12, 0.11, 0.12 ms | 0.12, 0.11, 0.11 ms |
| `kdb_bench` weekly candles of all 527 symbols (first run) | before: 354, 380 ms | 335, 348 ms |
| 100 INSERTs of 1,000 rows into an accounts table | audit off: 1.40, 1.46 s | `SET AUDIT ON`: 1.61, 1.92 s |
The differences are smaller than the noise between two runs. Only `SET AUDIT ON` costs something: about 15-30% on the writes of that table.
- **No per-row cost** in scans: the timeout flag is read once per 16,384 rows in the row engine and once per batch in the column engine; a watchdog thread sets it, so the check never reads the clock.
- Tables without `SET AUDIT ON`: one `if` per commit.
- Table rights: nothing for users without table rights; one short walk over the statement for users with them.
- `SHOW AUDIT` and the audit writer run on their own thread, never in the request.
---
## Limits
- Security events are written every 200 ms: a crash can lose the events of that moment (data-change audit rows are never lost: same transaction).
- A server on one folder path (`kdb serve D:\kdb\market`) has no security audit log (it has no `_system`). Data-change audit works there.
- The audit log is not signed: a person who can change the data files can change it too. Keep the data folder private and copy backups off the server.
- Table rights: no column rights (masking of columns in results is not built), no row rights. `CALL` and triggers ignore table rights.
- Two-factor login is only for HTTP logins (console, `/api/login`). HTTP Basic and the PostgreSQL port refuse two-factor users.
- The statement timeout does not stop a single very long step that has no rows in between (for example sorting one huge result); memory limits usually stop such a query first.
- `kdb sql`, `kdb shell` and library apps have no timeouts or limits.
- The trash keeps tables with named `UNIQUE`/`PRIMARY KEY` constraints only after the constraint work supports renaming them; such tables are dropped for good now.
- Older KDB programs refuse a database with audited tables, and a data folder with table rights, two-factor users or read-only settings (they would ignore them). Update every `kdb` program first.
- No encryption of the files yet (Part G).
---
Next: [Known limitations](known-limitations.md)
---
# 22. PostgreSQL schema compatibility
- Category: Tutorial
## What you will learn
The NChartPro app creates its PostgreSQL tables itself when it starts (`pgdb.CreateAllTables()`). This chapter shows what KDB does with those statements:
- **Part A.** PostgreSQL type names (`SERIAL`, `VARCHAR(n)`, `NUMERIC(p,s)`, `UUID`, `TEXT[]`, ...).
- **Part B.** `CREATE TABLE` forms: constraints, composite keys, `CREATE TABLE ... AS`, `TEMP` tables.
- **Part C.** Foreign keys with `ON DELETE CASCADE`, `SET NULL`, `SET DEFAULT`, `RESTRICT`.
- **Part D.** `ALTER TABLE` forms.
- **Part E.** PostgreSQL index forms: partial and expression indexes.
- **Part F.** Sequences: `nextval`, `currval`, `setval`.
- **Part G.** `CREATE SCHEMA`, `CREATE EXTENSION`, `COMMENT ON`, `DO $$ ... $$` blocks.
- **Part H.** `information_schema` and `pg_catalog` views.
- **Part I.** plpgsql trigger functions and `FOR EACH ROW` triggers: what KDB runs.
- **Part J.** Times: local time and UTC when you move data.
- What is **different** from PostgreSQL, what **older KDB** programs do, and **speed**.
Chapter 14 explains how to connect with the PostgreSQL protocol. This chapter is about the schema (tables, keys, indexes); chapter 23 is about queries and data changes (`ON CONFLICT`, `WITH`, subqueries, arrays). The output under each example is real.
## The short version
| PostgreSQL | KDB |
|---|---|
| `SERIAL`, `BIGSERIAL`, `GENERATED ... AS IDENTITY` | Works for a single-column `PRIMARY KEY` (`AUTO` id). |
| `VARCHAR(n)`, `CHAR(n)`, `NUMERIC(p,s)` | Works. Lengths, precision and scale are checked. |
| `UUID`, `TIME`, `INTERVAL`, `TEXT[]`, `JSONB`, `INET` | Works, with a KDB type underneath (Part A). |
| `PRIMARY KEY (a, b)`, `UNIQUE (a, b)`, `FOREIGN KEY (a, b)` | Works. |
| `ON DELETE CASCADE / SET NULL / SET DEFAULT / RESTRICT`, `ON UPDATE CASCADE` | Works, also inside `BEGIN ... COMMIT`. |
| `ALTER TABLE ... ALTER COLUMN ... TYPE / SET DEFAULT / SET NOT NULL`, `ADD CONSTRAINT` | Works. Rows are checked, or written again. |
| `CREATE UNIQUE INDEX ... (lower(email))`, `... WHERE cond` | Works. |
| `CREATE SEQUENCE`, `nextval('s')` | Works. A number is never given twice, also after a crash. |
| `information_schema.tables / columns / table_constraints`, `pg_indexes`, `pg_constraint`, ... | Read-only views. |
| `DO $$ BEGIN IF ... THEN ...; END IF; END $$` | Works for `IF`, `RAISE` and SQL statements. |
| `CREATE EXTENSION`, `CREATE SCHEMA`, `COMMENT ON` | Accepted. Nothing changes. |
| `CREATE FUNCTION ... RETURNS trigger LANGUAGE plpgsql` with a `BEFORE UPDATE ... FOR EACH ROW` trigger | Works for `IF`, `RAISE EXCEPTION`, `NEW.col := ...` and `RETURN NEW` (Part I). Other plpgsql is refused with a clear message. |
When the app's PostgreSQL startup DDL runs on KDB, **802 of 809** statements work (99%). Before this work it was 198 of 809. The 7 others are listed at the end of the chapter.
---
## Part A: Types
Write the PostgreSQL name. KDB keeps the name (so `SHOW CREATE TABLE` and `information_schema.columns` show it) and stores the value in a KDB type.
| PostgreSQL | KDB stores as | Rule |
|---|---|---|
| `SMALLINT`, `INT2` | `SHORT` | |
| `INTEGER`, `INT`, `INT4` | `INT` | |
| `BIGINT`, `INT8` | `LONG` | |
| `SMALLSERIAL`, `SERIAL`, `BIGSERIAL` | `SHORT` / `INT` / `LONG` `PRIMARY KEY AUTO` | Only as the single-column `PRIMARY KEY`. See below. |
| `GENERATED {ALWAYS \| BY DEFAULT} AS IDENTITY` | same as `SERIAL` | |
| `REAL`, `FLOAT4`, `FLOAT(1..24)` | `FLOAT` | |
| `DOUBLE PRECISION`, `FLOAT8`, `FLOAT(25..53)` | `DOUBLE` | `FLOAT` alone is KDB's 4-byte `FLOAT` (in PostgreSQL it is 8 bytes). |
| `NUMERIC(p,s)`, `DECIMAL(p,s)`, `NUMERIC(p)` | `DECIMAL(s)` | Rounded to `s` places (half away from zero). More than `p` digits → error `numeric field overflow`. `s` at most 18. |
| `NUMERIC` (no precision) | `DOUBLE` | About 15 exact digits. |
| `DECIMAL(s)` (one number) | `DECIMAL(s)` | KDB meaning: `s` is the **scale** (chapter 11). |
| `MONEY` | `DECIMAL(2)` | Rounded to 2 places. |
| `VARCHAR(n)`, `CHARACTER VARYING(n)` | `STRING` | At most `n` **characters** (letters of any language, such as Nepali, count as characters). |
| `CHAR(n)`, `BPCHAR` | `STRING` | At most `n` characters. Not padded with spaces. |
| `TEXT`, `VARCHAR`, `CITEXT`, `NAME` | `STRING` | `CITEXT` is not case-insensitive: use `lower(col)` (and a `lower(col)` index). |
| `BOOLEAN` | `BOOL` | |
| `DATE` | `DATE` | |
| `TIMESTAMP`, `TIMESTAMPTZ`, `TIMESTAMP WITH TIME ZONE` | `TIMESTAMP` | Milliseconds, UTC. Text with an offset (`+05:45`) is converted. See Part J. |
| `TIME`, `TIMETZ` | `STRING` | Checked and written as `HH:MM:SS[.fff][zone]`: `'9:30'` becomes `'09:30:00'`. |
| `INTERVAL` | `LONG` | Milliseconds. `'7 days'` is `604800000`. A month counts as 30 days. |
| `UUID` | `STRING` | Checked. Written in lower case with dashes. |
| `JSON`, `JSONB` | `JSON` | Chapter 18. `jsonb` operators like `@>` are not there. |
| `BYTEA` | `BYTES` | |
| `INET`, `CIDR`, `MACADDR`, `XML` | `STRING` | Not checked. |
| `type[]` (`TEXT[]`, `INT[]`, ...) | `JSON` array | `'{a,"b c",NULL}'` becomes `["a","b c",null]`. Numbers and booleans are checked. |
```sql
CREATE TABLE ev (id SERIAL PRIMARY KEY, at_time TIME, took INTERVAL, at TIMESTAMPTZ);
INSERT INTO ev (at_time, took, at) VALUES ('9:30', '1 day 02:00:00', '2026-09-11 10:00:00+05:45')
RETURNING at_time, took, at;
```
```text
table 'ev' created
at_time | took | at
---------+----------+-------------------------
09:30:00 | 93600000 | 2026-09-11T04:15:00.000Z
(1 row, 8.6 ms)
1 row(s) inserted
```
Errors use the PostgreSQL words, so the app's error checks still work. Over the PostgreSQL protocol they have the PostgreSQL error codes:
| Message | Code |
|---|---|
| `value too long for type VARCHAR(40) (48 characters, at most 40)` | `22001` |
| `numeric field overflow: ...` | `22003` |
| `invalid input syntax for type uuid: 'not-a-uuid'` | `22P02` |
### SERIAL must be the primary key
KDB gives `AUTO` ids only to the `PRIMARY KEY`. A `SERIAL` column that is not the primary key is refused, with the fix in the message:
```text
error: line 1, column 24: column 'code' is SERIAL (or IDENTITY) but not the PRIMARY KEY; in KDB such a column must be the table's single-column PRIMARY KEY (id SERIAL PRIMARY KEY). For other columns use a sequence: code INT NOT NULL DEFAULT nextval('t_serial_code_seq')
```
`AUTO` ids work like a sequence: a failed or rolled back insert also uses up an id, so there can be gaps.
---
## Part B: CREATE TABLE
A small forum, written as PostgreSQL:
```sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS public.forum_users (
user_id SERIAL PRIMARY KEY,
email VARCHAR(120) NOT NULL UNIQUE,
name VARCHAR(40) NOT NULL,
balance NUMERIC(12,2) NOT NULL DEFAULT 0 CHECK (balance >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS forum_posts (
post_id BIGSERIAL PRIMARY KEY,
user_id INTEGER REFERENCES forum_users(user_id) ON DELETE SET NULL,
parent_id BIGINT REFERENCES forum_posts(post_id) ON DELETE CASCADE,
title TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
public_id UUID NOT NULL DEFAULT gen_random_uuid()
);
CREATE TABLE IF NOT EXISTS post_likes (
post_id BIGINT NOT NULL,
user_id INT NOT NULL,
CONSTRAINT post_likes_pkey PRIMARY KEY (post_id, user_id),
CONSTRAINT post_likes_post_fk FOREIGN KEY (post_id) REFERENCES forum_posts(post_id) ON DELETE CASCADE,
CONSTRAINT post_likes_user_fk FOREIGN KEY (user_id) REFERENCES forum_users(user_id) ON DELETE CASCADE
);
COMMENT ON TABLE post_likes IS 'one row per like';
```
```text
extension 'pgcrypto' accepted: its common functions are built in or not needed
table 'forum_users' created
table 'forum_posts' created
table 'post_likes' created
comment accepted (KDB does not keep comments)
```
```sql
INSERT INTO forum_users (email, name) VALUES ('ram@example.com', 'Ram'), ('sita@example.com', 'Sita')
RETURNING user_id, balance, created_at;
INSERT INTO forum_posts (user_id, title, tags) VALUES (1, 'CITY results', '{bank,"q1 2083"}') RETURNING post_id, tags;
```
```text
user_id | balance | created_at
--------+---------+-------------------------
1 | 0.00 | 2026-09-11T19:01:54.203Z
2 | 0.00 | 2026-09-11T19:01:54.203Z
(2 rows, 2.1 ms)
2 row(s) inserted
post_id | tags
--------+-------------------
1 | ["bank","q1 2083"]
(1 row, 2.1 ms)
1 row(s) inserted
```
The rules are checked for every written row:
```text
INSERT INTO forum_users (email, name, balance) VALUES ('hari@example.com', 'Hari', 12.345) RETURNING user_id, balance;
user_id | balance
--------+--------
3 | 12.35
INSERT INTO forum_users (email, name) VALUES ('ram@example.com', 'Ram again');
error: column 'email' must be unique: 'ram@example.com' is already used
INSERT INTO forum_posts (user_id, title) VALUES (99, 'Nobody');
error: column 'user_id' = '99' has no matching row in forum_users(user_id)
INSERT INTO forum_users (email, name, balance) VALUES ('gita@example.com', 'Gita', -5);
error: CHECK constraint 'forum_users_balance_check' of table 'forum_users' failed: balance >= 0 is false for the row user_id='5', balance='-5.00'
```
### What CREATE TABLE accepts
| Form | What KDB does |
|---|---|
| `IF NOT EXISTS`, `public.name` | As in PostgreSQL. `public.` and `pg_catalog.` are removed from names. |
| `NOT NULL`, `NULL`, `DEFAULT expr`, `CHECK (...)`, `UNIQUE`, `PRIMARY KEY` | As in PostgreSQL. |
| `REFERENCES t` (no column) | Refers to the primary key of `t`. |
| `CONSTRAINT name ...` | The name is kept. It is used in errors, `DROP CONSTRAINT` and the catalog views. |
| `PRIMARY KEY (a, b)` | Rows are stored by the key. `INSERT OR REPLACE` uses it. |
| `UNIQUE (a, b)` | A unique index named like PostgreSQL (`table_a_b_key`). |
| `FOREIGN KEY (a, b) REFERENCES t (x, y)` | Checked when any part is not NULL (`MATCH SIMPLE`). |
| `GENERATED ALWAYS AS (expr) STORED` | A KDB computed column (worked out when read, not stored). |
| `CREATE TABLE t AS SELECT ...` `[WITH NO DATA]` | Types come from the query. Table and rows are written together. |
| `CREATE TEMP` / `TEMPORARY` / `UNLOGGED TABLE` | A normal table (it stays after the session). |
| `WITH (fillfactor = 70)`, `WITHOUT OIDS`, `TABLESPACE x` | Accepted and ignored. |
| `INHERITS (...)`, `PARTITION BY RANGE (...)` | Refused. Use KDB `TIMESERIES` tables (chapter 3). |
| `DEFERRABLE` | Refused. KDB checks keys at every statement. |
Table constraints (`PRIMARY KEY (...)`, `CONSTRAINT ... CHECK`) must come **after** the columns.
---
## Part C: Foreign key actions
`forum_posts.parent_id` has `ON DELETE CASCADE`, and `post_likes` has `ON DELETE CASCADE` to both tables. Add replies and likes, then delete post 1:
```sql
INSERT INTO forum_posts (user_id, parent_id, title) VALUES (2, 1, 'Good numbers'), (1, 2, 'Agree');
INSERT INTO post_likes VALUES (1, 2), (2, 1), (3, 2);
DELETE FROM forum_posts WHERE post_id = 1;
SELECT post_id, user_id, parent_id, title FROM forum_posts ORDER BY post_id;
SELECT * FROM post_likes ORDER BY post_id;
```
```text
2 row(s) inserted
3 row(s) inserted
1 row(s) deleted
post_id | user_id | parent_id | title
--------+---------+-----------+------
(0 rows, 0.1 ms)
post_id | user_id
--------+--------
(0 rows, 0.1 ms)
```
Post 1 took its reply (2), the reply's reply (3) and all three likes with it. `forum_posts.user_id` has `ON DELETE SET NULL`:
```sql
INSERT INTO forum_posts (user_id, title) VALUES (2, 'Sita post');
DELETE FROM forum_users WHERE user_id = 2;
SELECT post_id, user_id, title FROM forum_posts ORDER BY post_id;
```
```text
1 row(s) inserted
1 row(s) deleted
post_id | user_id | title
--------+---------+----------
5 | NULL | Sita post
(1 row, 0.3 ms)
```
| Action | What happens to the rows that refer to the deleted (or changed) row |
|---|---|
| `NO ACTION` (default), `RESTRICT` | The statement is refused: `cannot remove forum_users(user_id) = '1': table 'forum_posts' column 'user_id' still refers to it (foreign key 'forum_posts_user_id_fkey')`. |
| `CASCADE` | `ON DELETE`: they are deleted too (and their children, at most 32 levels). `ON UPDATE`: they get the new key. |
| `SET NULL` | Their column becomes NULL. Refused if the column is `NOT NULL`. |
| `SET DEFAULT` | Their column gets its `DEFAULT`. The default must exist in the parent. |
The rules:
- All changes of one statement, with every cascade, are **one transaction**. If one part fails (a `CHECK`, a `NOT NULL`, a `RESTRICT` deeper down), nothing changes.
- Inside `BEGIN ... COMMIT` it works the same. `ROLLBACK` brings back the parent and all children.
- After a crash, the log has the whole change or none of it.
- KDB finds the child rows with the child's key or an index when there is one. Without one, it reads the child table. For big child tables, add an index on the foreign key column (`CREATE INDEX ... ON child (parent_id)`), as in PostgreSQL.
- A table that another table refers to cannot be dropped (`DROP TABLE ... CASCADE` is not supported).
- In one `INSERT` with many rows, a row can refer to a row **before** it in the same statement (a tree). PostgreSQL also allows a row that comes later.
---
## Part D: ALTER TABLE
```sql
ALTER TABLE forum_users ADD COLUMN IF NOT EXISTS phone VARCHAR(15), ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE forum_users ALTER COLUMN name TYPE VARCHAR(80), ALTER COLUMN balance SET DEFAULT 100;
ALTER TABLE forum_users ALTER COLUMN phone SET NOT NULL;
```
```text
table 'forum_users' altered
table 'forum_users' altered
error: column 'phone' of table 'forum_users' contains NULL values; set them first (UPDATE ... SET phone = ... WHERE phone IS NULL)
```
| Form | What KDB does |
|---|---|
| `ADD [COLUMN] [IF NOT EXISTS] col type [rules]` | Old rows get the `DEFAULT`. |
| `DROP [COLUMN] [IF EXISTS] col [CASCADE]` | Refused while an index, `CHECK` or multi-column key uses the column; `CASCADE` drops them. |
| `ALTER [COLUMN] col SET DEFAULT expr` / `DROP DEFAULT` | Only new rows. |
| `ALTER [COLUMN] col SET NOT NULL` / `DROP NOT NULL` | `SET NOT NULL` checks all rows first. |
| `ALTER [COLUMN] col [SET DATA] TYPE type [USING expr]` | Every row is converted and written again, in the same transaction as the change. A value that does not fit stops it and nothing changes. |
| `RENAME [COLUMN] a TO b`, `RENAME TO new_name` | As in PostgreSQL. |
| `ADD [CONSTRAINT name] UNIQUE (...)` / `PRIMARY KEY (...)` / `FOREIGN KEY (...) REFERENCES ...` / `CHECK (...)` | Existing rows are checked. `ADD PRIMARY KEY` writes the rows again, stored by the new key. |
| `... NOT VALID` | For `CHECK` and `FOREIGN KEY`: old rows are not checked, new rows are. |
| `DROP CONSTRAINT [IF EXISTS] name` | Any named constraint, except the `PRIMARY KEY` (rows are stored by it). |
| Several actions with commas | All of them or none. |
| `ALTER TABLE IF EXISTS`, `ONLY`, `OWNER TO`, `VALIDATE CONSTRAINT` | Accepted. |
| `ENABLE` / `DISABLE TRIGGER name` | KDB triggers (chapter 13). |
`ALTER TABLE` and `CREATE INDEX` cannot be used between `BEGIN` and `COMMIT` (chapter 13). A `DO` block can use them, outside a transaction (Part G).
---
## Part E: Indexes
```sql
CREATE UNIQUE INDEX IF NOT EXISTS uq_forum_users_email_lower ON forum_users (lower(email));
INSERT INTO forum_users (email, name) VALUES ('RAM@example.com', 'Ram 2');
CREATE UNIQUE INDEX IF NOT EXISTS uq_one_admin ON forum_users (is_admin) WHERE is_admin;
CREATE INDEX IF NOT EXISTS idx_posts_tags ON forum_posts USING gin (tags);
SELECT indexname FROM pg_indexes WHERE tablename = 'forum_users' ORDER BY indexname;
```
```text
index 'uq_forum_users_email_lower' created on table 'forum_users' (2 rows)
error: duplicate value violates unique index 'uq_forum_users_email_lower' on table 'forum_users': a row with the same (lower(email)) already exists
index 'uq_one_admin' created on table 'forum_users' (0 rows)
index 'idx_posts_tags' created on table 'forum_posts' (1 rows)
indexname
--------------------------
forum_users_email_key
forum_users_pkey
uq_forum_users_email_lower
uq_one_admin
(4 rows, 0.1 ms)
```
| Form | What KDB does |
|---|---|
| `CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] [name] ON [ONLY] t [USING btree \| hash \| gin \| gist] (...)` | Accepted. Without a name, KDB names it like PostgreSQL (`t_col_idx`). `USING` is only remembered. |
| `(col DESC NULLS LAST, col text_pattern_ops)` | Accepted. The order words are remembered; KDB indexes work both ways. |
| `(lower(email))`, `(COALESCE(a, ''))` | An expression index. |
| `... WHERE condition` | A partial index: only rows where the condition is true. |
| `INCLUDE (...)`, `WITH (...)`, `TABLESPACE` | Accepted and ignored. |
| An index on a `GENERATED` column | Indexes the column's expression. |
| `DROP INDEX [CONCURRENTLY] [IF EXISTS] a, b [CASCADE]` | Drops them. The index of a `UNIQUE` constraint needs `ALTER TABLE ... DROP CONSTRAINT`. |
**Unique** expression and partial indexes are checked on every write (also inside transactions and after `ROLLBACK`). **Non-unique** ones are built and used too: `WHERE lower(email) = 'a@x.np'` uses `(lower(email))`, and a query whose `WHERE` has every part of a partial index's own `WHERE` uses that index. `EXPLAIN` shows it as `use index (...)`. An expression key part works for `expr = constant` only (no ranges, no `IN (...)`, no `ORDER BY` on it); those queries scan.
---
## Part F: Sequences
```sql
CREATE SEQUENCE IF NOT EXISTS invoice_no START WITH 1000;
SELECT nextval('invoice_no') AS a, nextval('invoice_no') AS b;
SELECT setval('invoice_no', 5000);
CREATE TABLE invoices (invoice_no BIGINT PRIMARY KEY DEFAULT nextval('invoice_no'), amount NUMERIC(12,2));
INSERT INTO invoices (amount) VALUES (10), (20) RETURNING invoice_no;
```
```text
sequence 'invoice_no' created
a | b
-----+-----
1000 | 1001
(1 row, 2.8 ms)
setval('invoice_no', 5000)
--------------------------
5000
(1 row, 2.0 ms)
table 'invoices' created
invoice_no
----------
5001
5002
(2 rows, 3.9 ms)
2 row(s) inserted
```
| Form | Meaning |
|---|---|
| `CREATE SEQUENCE [IF NOT EXISTS] s [AS type] [INCREMENT [BY] n] [MINVALUE n] [MAXVALUE n] [START [WITH] n] [CACHE n] [[NO] CYCLE] [OWNED BY t.c]` | A new sequence. |
| `ALTER SEQUENCE s RESTART [WITH n]` / `INCREMENT BY n` / ... | Changes it. |
| `DROP SEQUENCE [IF EXISTS] s, ...` | Removes it. |
| `nextval('s')` | The next number. |
| `currval('s')` | The last `nextval` of **this session**. Before that: an error, as in PostgreSQL. |
| `setval('s', n [, is_called])` | The next `nextval` gives `n + 1` (or `n` with `false`). |
| `lastval()` | The last `nextval` of this session, of any sequence. |
**A number is never given twice.** Not after a rollback, not after a restart, not after a crash. KDB writes a mark to the log before it gives out the next 32 numbers. After a crash, the sequence starts after the mark, so a few numbers can be skipped (as in PostgreSQL).
Over HTTP every request is a new session, so `currval` works only inside one request (`SELECT nextval('s'); SELECT currval('s')`). Over the PostgreSQL protocol, the session is the connection.
---
## Part G: Other statements
| Statement | What KDB does |
|---|---|
| `CREATE SCHEMA [IF NOT EXISTS] name` | Accepted: `schema 'reporting' accepted: KDB has no schemas; use a separate database for its tables`. |
| `CREATE EXTENSION [IF NOT EXISTS] pgcrypto` | Accepted for `pgcrypto`, `uuid-ossp`, `citext`, `plpgsql`, `pg_trgm`, `btree_gin`, `btree_gist`, `unaccent`. Other names are refused. |
| `COMMENT ON TABLE / COLUMN / INDEX ... IS '...'` | Accepted. The comment is not kept. |
| `CREATE TEMP TABLE t AS SELECT ...` | A normal table with the rows (`table 'tmp_ids' created with 1 row(s)`). |
### DO blocks
The app uses `DO $$ ... $$` blocks to change the schema only once:
```sql
DO $$ BEGIN
IF to_regclass('public.forum_posts') IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'forum_posts' AND column_name = 'views') THEN
ALTER TABLE forum_posts ADD COLUMN views INT NOT NULL DEFAULT 0;
RAISE NOTICE 'column views added';
END IF;
END $$;
```
```text
table 'forum_posts' altered; NOTICE: column views added
```
A DO block may use:
- SQL statements (`SELECT`, `INSERT`, `UPDATE`, `DELETE`, `ALTER TABLE`, `CREATE INDEX`, `DROP INDEX`, ...), each ending with `;`.
- `IF ... THEN ... [ELSIF ... THEN ...] [ELSE ...] END IF;` The condition can use `EXISTS (SELECT ...)`, `(SELECT count(*) ...)`, `to_regclass('name')`, `information_schema` and `pg_catalog` views.
- `RAISE NOTICE / WARNING / INFO 'text %', value;` adds a line to the message. `RAISE EXCEPTION 'text %', value;` stops with an error.
- `NULL;`
`DECLARE`, `PERFORM`, `EXECUTE`, loops, `RETURN` and `EXCEPTION` handlers are refused with a message. Write a KDB procedure (chapter 13), or do the work in the Go code.
**A DO block is not one transaction.** Each statement is saved when it runs. If a later statement fails, the earlier ones stay. The app's blocks are written to run again safely (they check first), so this is fine for them.
Of the app's 23 DO blocks, 19 work. Two fail only because the test tool joins the lines of the Go source and a `--` comment then hides the rest of the block (in the app they work). One is built by Go code at run time. One (`actionsimport`) compares `count(*)` with a value the tool cannot fill in.
---
## Part H: Catalog views
These read-only views describe the current database. They are made when a query reads them and kept until the schema changes.
| `information_schema` | `pg_catalog` |
|---|---|
| `tables`, `columns`, `views`, `schemata`, `sequences` | `pg_namespace`, `pg_class`, `pg_attribute`, `pg_type`, `pg_database` |
| `table_constraints`, `key_column_usage`, `constraint_column_usage` | `pg_index`, `pg_indexes`, `pg_constraint` |
| `referential_constraints`, `check_constraints` | `pg_tables`, `pg_views`, `pg_sequences` |
Every table is in the schema `public`.
```sql
SELECT column_name, data_type, character_maximum_length AS len, numeric_precision AS p, numeric_scale AS s, is_nullable, column_default
FROM information_schema.columns WHERE table_name = 'forum_users' ORDER BY ordinal_position;
```
```text
column_name | data_type | len | p | s | is_nullable | column_default
------------+--------------------------+------+------+------+-------------+---------------------------------------------
user_id | integer | NULL | 32 | 0 | NO | nextval('forum_users_user_id_seq'::regclass)
email | character varying | 120 | NULL | NULL | NO | NULL
name | character varying | 80 | NULL | NULL | NO | NULL
balance | numeric | NULL | 12 | 2 | NO | 100
created_at | timestamp with time zone | NULL | NULL | NULL | NO | now()
mobile | character varying | 15 | NULL | NULL | YES | NULL
is_admin | boolean | NULL | NULL | NULL | NO | FALSE
(7 rows, 0.3 ms)
```
```sql
SELECT constraint_name, constraint_type FROM information_schema.table_constraints WHERE table_name = 'post_likes' ORDER BY 1;
```
```text
constraint_name | constraint_type
-------------------+----------------
post_likes_pkey | PRIMARY KEY
post_likes_post_fk | FOREIGN KEY
post_likes_user_fk | FOREIGN KEY
(3 rows, 0.1 ms)
```
The views have the columns apps and tools use most. They are not complete copies of PostgreSQL's views: `psql`'s `\d` and some ORMs ask for more and may still fail.
---
## Part I: plpgsql functions and triggers
KDB runs a small part of plpgsql: a **trigger function** (`RETURNS trigger ... LANGUAGE plpgsql`) with a `BEFORE UPDATE ... FOR EACH ROW` trigger. This is the form of the app's two trigger functions. KDB runs the body for every changed row.
The body may use:
- `IF ... THEN ... [ELSIF ...] [ELSE ...] END IF`
- `RAISE EXCEPTION 'text with % places', value, ...`
- `NEW.col := value` (change the new row)
- `NEW.col` and `OLD.col` in conditions and values
- `RETURN NEW`
**1. Refuse a change** (`user_register_block_identity_update`):
```sql
CREATE TABLE user_register (user_id SERIAL PRIMARY KEY, email VARCHAR(200), referral_code VARCHAR(20));
INSERT INTO user_register (email, referral_code) VALUES ('ram@example.com', 'R1'), ('sita@example.com', 'S1');
CREATE OR REPLACE FUNCTION user_register_block_identity_update() RETURNS trigger AS $fn$
BEGIN
IF OLD.email IS NOT NULL AND OLD.email <> '' AND NEW.email IS DISTINCT FROM OLD.email THEN
RAISE EXCEPTION 'user_register.email is write-once (user %)', OLD.user_id;
END IF;
RETURN NEW;
END;
$fn$ LANGUAGE plpgsql;
CREATE TRIGGER trg_user_register_immutable BEFORE UPDATE ON user_register
FOR EACH ROW EXECUTE FUNCTION user_register_block_identity_update();
UPDATE user_register SET referral_code = 'R2' WHERE user_id = 1;
UPDATE user_register SET email = 'new@example.com' WHERE user_id = 2;
```
```text
trigger function 'user_register_block_identity_update' created (plpgsql, run by KDB for every row a BEFORE UPDATE trigger sees)
row trigger 'trg_user_register_immutable' created on table 'user_register': it runs 'user_register_block_identity_update' for every row of a BEFORE UPDATE
1 row(s) updated
error: trigger 'trg_user_register_immutable' on table 'user_register' stopped the change: user_register.email is write-once (user 2)
```
The `%` is filled in from the row's own `OLD.user_id`. Nothing of the refused `UPDATE` is written.
**2. Change the new row** (`ticker_alias_reset_confirmation`):
```sql
CREATE TABLE ticker_alias (alias_id SERIAL PRIMARY KEY, swap_ratio NUMERIC(10,4), confirmed BOOLEAN NOT NULL DEFAULT FALSE, notes TEXT);
INSERT INTO ticker_alias (swap_ratio, confirmed) VALUES (1.5, TRUE);
CREATE FUNCTION ticker_alias_reset_confirmation() RETURNS trigger AS $$
BEGIN
IF OLD.confirmed AND NEW.swap_ratio IS DISTINCT FROM OLD.swap_ratio THEN
NEW.confirmed := FALSE;
NEW.notes := COALESCE(NEW.notes || ' | ', '') || 'confirmation reset';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_ticker_alias_reset_confirmation BEFORE UPDATE ON ticker_alias
FOR EACH ROW EXECUTE FUNCTION ticker_alias_reset_confirmation();
UPDATE ticker_alias SET swap_ratio = 1.6 WHERE alias_id = 1;
SELECT alias_id, swap_ratio, confirmed, notes FROM ticker_alias;
```
```text
alias_id | swap_ratio | confirmed | notes
---------+------------+-----------+-------------------
1 | 1.6000 | false | confirmation reset
(1 row, 0.2 ms)
```
What KDB refuses, with a clear message when you create it (nothing is accepted and then quietly not run):
| You write | KDB says |
|---|---|
| `AFTER UPDATE ... FOR EACH ROW` | `AFTER ... FOR EACH ROW triggers are not supported by KDB; a plpgsql row trigger must be BEFORE (...)` |
| `BEFORE INSERT` or `BEFORE DELETE ... FOR EACH ROW` | `only BEFORE UPDATE ... FOR EACH ROW plpgsql triggers are supported by KDB; INSERT row triggers are not (...)` |
| `DECLARE`, `PERFORM`, loops or SQL statements in the body | the name of the part that is not supported, and what the body may use |
| A function that does not `RETURNS trigger` (`LANGUAGE sql`, for example) | a `WARNING`: the function is recorded but **not run**. Write a KDB function or procedure for it (chapter 13). |
For other work, write a **KDB trigger** (chapter 13). It runs once per statement and sees all changed rows in the tables `OLD` and `NEW`. A KDB trigger and a plpgsql row trigger can be on the same table.
The PostgreSQL function of the app lets a person skip the check with `SET LOCAL nchartpro.allow_identity_update = 'on'`. In KDB, an admin runs `ALTER TABLE user_register DISABLE TRIGGER trg_user_register_immutable`, makes the fix, and runs `ENABLE TRIGGER` again.
---
## Part J: Times (local time and UTC)
KDB stores every `TIMESTAMP` in **UTC** (chapter 4). The NChartPro PostgreSQL server runs with `TZ=Asia/Kathmandu` (Nepal time, +05:45). That matters when you move data:
| PostgreSQL column | What it holds | When you move it to KDB |
|---|---|---|
| `TIMESTAMPTZ` | An exact moment. | Export it **with** its offset (`2026-08-25 10:08:38+05:45`). KDB converts it to UTC. Nothing else to do. |
| `TIMESTAMP` (no time zone) | **Local wall-clock time** (for this app, Nepal time). `DEFAULT CURRENT_TIMESTAMP` and `now()` wrote local time. | **Subtract the zone offset** (5 hours 45 minutes here), or add `+05:45` to the text before you insert it. |
| `DATE` | A calendar day. | Keep it as it is. |
| `TIME` | A clock time ("show at 10:00"). | Keep it as it is. It stays local clock time. |
```sql
SELECT ('2026-08-25 10:08:38' || '+05:45')::TIMESTAMP AS utc;
SELECT '2026-08-25 10:08:38'::timestamp - INTERVAL '5 hours 45 minutes' AS utc;
```
```text
utc
------------------------
2026-08-25T04:23:38.000Z
(1 row, 0.1 ms)
utc
------------------------
2026-08-25T04:23:38.000Z
(1 row, 0.1 ms)
```
After the move:
- `now()` and `CURRENT_TIMESTAMP` in KDB are **UTC**. `WHERE expires_at > now()` is right when both sides are UTC.
- To show local time, add the zone offset (here 5 hours 45 minutes, `20700000` ms): `to_char(blocked_at + 20700000, 'YYYY-MM-DD HH24:MI:SS')`.
- To compare a `TIME` column with the local clock, use the local time: `to_char(now() + 20700000, 'HH24:MI:SS')`.
- Over the PostgreSQL protocol, start `kdb serve` with `--pg-timestamptz` so Go gets the exact moment (chapter 14).
**Never mix** local times and UTC times in one column. Move all rows of a table the same way.
---
## What is different from PostgreSQL
- `SERIAL` and `IDENTITY` only for the single-column primary key.
- `NUMERIC` without precision is a `DOUBLE`. `NUMERIC(p,s)` allows at most 18 decimal places, and the value times 10^s must fit a 64-bit number (about 18 digits).
- `FLOAT` alone is 4 bytes. `CHAR(n)` is not padded. `CITEXT` is not case-insensitive.
- `INTERVAL` is a number of milliseconds; a month is 30 days.
- `TIMESTAMPTZ` and `TIMESTAMP` are the same KDB type (UTC).
- `GENERATED ... STORED` columns are worked out when read, not stored.
- A `DO` block is not one transaction.
- Expression index keys work for `expr = constant` only; ranges, `IN (...)` and `ORDER BY` on them scan.
- The `PRIMARY KEY` of a table cannot be dropped. `DROP TABLE ... CASCADE`, `DEFERRABLE` constraints, `INHERITS` and declarative partitions are not supported.
- Table constraints must come after the columns.
- `CREATE`, `ALTER` and `DROP` are not allowed inside `BEGIN ... COMMIT`.
- Schemas other than `public` do not exist.
## The app's startup DDL on KDB
`node tests/app_compat/run_compat.mjs` replays the app's startup statements: 809 for PostgreSQL and 39 for QuestDB. 802 PostgreSQL statements work (checked on 2026-09-23). The 7 that do not are faults of the test tool, not of KDB:
| Count | What | Why |
|---|---|---|
| 3 | 2 DO blocks and 1 plpgsql function with a `--` comment inside | The test tool joins the lines of the Go source, so the comment hides the rest (the function then has no `END`). With the app's real text, with its line breaks, they work. |
| 1 | The trigger of that function | It fails because its function failed. |
| 1 | The other plpgsql trigger | The test schema still has an old hand-written KDB trigger with the same name. |
| 2 | DO blocks built by Go code at run time (`{{...}}` parts) | The test tool cannot fill in the parts. |
Over the whole app (2,143 statements: reads, writes and startup DDL, PostgreSQL and QuestDB), **1,971 work (92%)** on the same day.
The QuestDB statements (`SYMBOL INDEX`, `TIMESTAMP(ts)`, `DEDUP UPSERT KEYS`, `DROP PARTITION LIST`) are not PostgreSQL. KDB has its own `TIMESERIES` form for them (chapter 10).
## Older KDB programs
A database is written in a way older KDB programs understand, **until** you use a new feature:
| New feature | Stored as | An older KDB program |
|---|---|---|
| PostgreSQL types, named `UNIQUE`, `PRIMARY KEY (a, b)`, `FOREIGN KEY (a, b)`, partial or expression indexes | New parts of the table description (`T`, `K`, `F`, `X`) | Refuses to open the database: `needs a newer KDB`. Nothing is damaged. |
| Sequences | A new file `sequences` and log records | Refuses to open the database: `needs a newer KDB`. |
| plpgsql trigger functions and row triggers | New kinds in the file `routines` (feature `plpgsql_triggers`) | Refuses to open the database: `needs a newer KDB`. |
| `ALTER COLUMN TYPE`, `ADD PRIMARY KEY` | Rows written again, in one transaction with the change | Normal rows. |
Tables that use none of this stay in the old format. **Update every `kdb` program before you run the app's PostgreSQL schema on a database.**
## Speed
- Plain KDB tables pay nothing: the new checks run only for columns with a PostgreSQL rule.
- A `UNIQUE` column uses an index in memory, so an insert does not read the table.
- Foreign key actions look up the child rows with keys and indexes. A child table without an index on the foreign key column is read in full for each deleted parent.
- The catalog views are made once per schema change.
- A `CREATE TABLE`, `ALTER TABLE` or `CREATE INDEX` that changes something takes about 20 ms on the test PC (it saves the table descriptions). An `IF NOT EXISTS` that changes nothing is faster. The whole app check (3,024 requests, with all startup DDL) took 15 seconds.
- `ALTER COLUMN ... TYPE` and `ADD PRIMARY KEY` write every row again: slow for very big tables.
---
# 23. PostgreSQL queries and data changes
- Category: Tutorial
## What you will learn
Chapter 22 is about the app's tables. This chapter is about the app's **queries and data changes**: the SQL a PostgreSQL app sends every day.
- **Part A.** Upserts: `INSERT ... ON CONFLICT`, `RETURNING`, `xmax`.
- **Part B.** `INSERT ... SELECT`, `UPDATE ... FROM`, `DELETE ... USING`, subqueries in data changes.
- **Part C.** `WITH` queries: plain, `RECURSIVE`, and `WITH x AS (UPDATE ...)`.
- **Part D.** Correlated subqueries, `EXISTS`, `LATERAL`, and why they stay fast.
- **Part E.** More query forms: `FILTER`, `DISTINCT ON`, `UNION` / `INTERSECT` / `EXCEPT`, `GROUP BY 1`.
- **Part F.** Casts, times and intervals.
- **Part G.** Arrays and JSON.
- **Part H.** Advisory locks.
- **Part I.** QuestDB apps: `ALTER DATABASE ... SET dialect`, `/imp`, `to_str`.
- What is **different** from PostgreSQL, and **speed**.
The output under each example is real (`kdb sql`). Times print in KDB's style (`2026-09-10T05:20:00.000Z`); over the PostgreSQL protocol they have PostgreSQL's style.
## The short version
| PostgreSQL | KDB |
|---|---|
| `INSERT ... ON CONFLICT (key) DO NOTHING / DO UPDATE SET c = EXCLUDED.c` | Works, with `WHERE`, `ON CONSTRAINT` and `RETURNING (xmax = 0)`. |
| `INSERT ... SELECT`, `DEFAULT VALUES` | Works. |
| `UPDATE t SET ... FROM s WHERE ...`, `DELETE FROM t USING s WHERE ...` | Works. |
| Subqueries in `SET`, `WHERE` and `VALUES` | Works, correlated ones too. |
| `WITH`, `WITH RECURSIVE`, `WITH x AS (UPDATE ... RETURNING ...)` | Works. A statement with data-changing `WITH` queries is one transaction. |
| Correlated subqueries, `EXISTS`, `IN (SELECT ...)`, `LATERAL` | Works. The usual shapes become hash lookups. |
| `FILTER (WHERE ...)`, `agg(x ORDER BY y)`, `DISTINCT ON`, `GROUP BY 1`, `NULLS FIRST` | Works. |
| `UNION`, `INTERSECT`, `EXCEPT` (with or without `ALL`), `(VALUES ...) AS v(a, b)` | Works. |
| `x::type`, `CAST`, `DATE '...'`, `INTERVAL '...'`, `E'...'`, `CURRENT_TIMESTAMP` | Works. Casts round numbers as PostgreSQL does. |
| `INTERVAL` arithmetic, `AT TIME ZONE`, `EXTRACT(EPOCH FROM interval)`, `make_interval` | Works (time zones: fixed offsets and zones without daylight saving, such as `Asia/Kathmandu`; see Part F). |
| `= ANY($1)` with Go slices, `ARRAY[...]`, `array_agg`, `unnest`, `TEXT[]` results | Works, in text and binary form. |
| `jsonb_build_object`, `json_agg`, `jsonb_set`, `->`, `->>`, `#>>` | Works. JSON sorts like `jsonb` (9 before 10). |
| `pg_advisory_lock`, `pg_try_advisory_xact_lock`, ... | Real locks between sessions. |
When all 2143 statements of the app run on KDB, **1971** work over HTTP (92.0%) and **1952** over the PostgreSQL protocol (91.1%) (checked on 2026-09-23). Before this work (and the schema work of chapter 22) it was 950 and 930. Most of the rest come from the test data or from table definitions the test schema left out (see the end).
---
## Part A: Upserts
`ON CONFLICT` says what to do when a new row has the key of a row that is there.
```sql
CREATE TABLE page_hits (page TEXT PRIMARY KEY, hits BIGINT NOT NULL, last_at TIMESTAMP);
INSERT INTO page_hits VALUES ('/chart', 5, '2026-09-10T05:00:00Z');
INSERT INTO page_hits (page, hits, last_at)
VALUES ('/chart', 1, '2026-09-11T05:00:00Z'), ('/news', 1, '2026-09-11T05:00:00Z')
ON CONFLICT (page) DO UPDATE SET hits = page_hits.hits + EXCLUDED.hits, last_at = EXCLUDED.last_at
RETURNING page, hits, (xmax = 0) AS inserted;
```
```
page | hits | inserted
-------+------+---------
/chart | 6 | false
/news | 1 | true
1 row(s) inserted, 1 updated
```
- `EXCLUDED.column` is the value of the new row. `page_hits.column` is the value that is there now.
- Write the table name (or the alias of `INSERT INTO t AS a`) before a column. A plain name is ambiguous in PostgreSQL.
- `RETURNING` gives the rows that were inserted or updated. `xmax` is 0 for an inserted row and 1 for an updated one.
- `DO UPDATE ... WHERE cond`: rows that do not match stay as they are and are not returned.
- `DO NOTHING` skips the row. Without a key list it means "any unique key".
```sql
INSERT INTO page_hits (page, hits) VALUES ('/news', 100) ON CONFLICT (page) DO NOTHING RETURNING page;
```
```
(0 rows)
0 row(s) inserted, 1 skipped (already there)
```
**Which keys.** The key list must be the primary key, a `DEDUP KEY`, a `UNIQUE` column or constraint, or a `UNIQUE INDEX` with exactly those columns (a partial index needs `ON CONFLICT (cols) WHERE ...`). `ON CONFLICT ON CONSTRAINT name` names it (`table_pkey` for the primary key). Another list is an error, as in PostgreSQL: "there is no unique key on (...)".
**Rules from PostgreSQL.**
- A key with a NULL never clashes.
- Two new rows with the same key in one `DO UPDATE` statement are an error ("cannot change the same row twice"). With `DO NOTHING` the first one wins.
- `SERIAL` numbers are used also for rows that end as updates (so ids can have gaps).
**Speed.** `DO NOTHING`, and `DO UPDATE SET` of every other column from `EXCLUDED`, on the table's own key, run as `INSERT OR IGNORE` / `INSERT OR REPLACE`: big batches go in without looking rows up one by one. Other forms look each row up by its key (or index).
## Part B: Copying and joined changes
`INSERT ... SELECT` copies the rows of a query. `DEFAULT VALUES` writes one row of defaults.
```sql
INSERT INTO ledger (user_id, amount, balance_after) SELECT user_id, 25, balance FROM wallets WHERE balance > 0;
INSERT INTO logs DEFAULT VALUES;
```
`UPDATE ... FROM` changes rows with values from other tables. `DELETE ... USING` deletes rows that have a match.
```sql
UPDATE wallets w SET balance = v.balance
FROM (VALUES (1, 10), (2, 20), (9, 90)) AS v(user_id, balance)
WHERE w.user_id = v.user_id
RETURNING w.user_id, w.balance;
```
```
user_id | balance
--------+--------
1 | 10
2 | 20
2 row(s) updated
```
```sql
DELETE FROM ledger l USING wallets w WHERE l.user_id = w.user_id AND w.balance < 15 RETURNING l.id;
```
- A row of the table changes once, also when it matches several rows of `FROM` (as in PostgreSQL, one of them is used).
- The join is a hash join: `WHERE w.user_id = v.user_id` does not compare every pair.
- Subqueries work in `SET`, `WHERE` and `VALUES`, also ones that use the row being changed:
```sql
UPDATE tickets t SET vote_count = (SELECT count(*) FROM votes v WHERE v.ticket_id = t.ticket_id);
DELETE FROM tickets WHERE NOT EXISTS (SELECT 1 FROM votes WHERE votes.ticket_id = tickets.ticket_id);
UPDATE ai_models SET is_default = TRUE WHERE (provider, model) = (SELECT provider, model FROM ai_models ORDER BY rank LIMIT 1);
```
## Part C: WITH queries
A `WITH` query names a result for the statement. KDB runs it the first time the statement reads it, once, and keeps its rows for the statement.
`WITH RECURSIVE` runs its second part again on the rows found last, until no new rows come. `UNION` (without `ALL`) stops at rows it has seen, so a loop in the data ends.
```sql
WITH RECURSIVE upline(user_id, referred_by, lvl) AS (
SELECT user_id, referred_by, 0 FROM referrals WHERE user_id = 4
UNION ALL
SELECT r.user_id, r.referred_by, u.lvl + 1 FROM referrals r JOIN upline u ON r.user_id = u.referred_by WHERE u.lvl < 20
)
SELECT user_id, lvl FROM upline ORDER BY lvl;
```
```
user_id | lvl
--------+----
4 | 0
3 | 1
2 | 2
1 | 3
```
A recursive query that never stops is stopped after 100,000 rounds or 10 million rows, with an error.
**WITH queries that change data.** The app's wallet top-up changes a row, writes a ledger row from the changed row, and returns it, in one statement:
```sql
WITH upd AS (UPDATE wallets SET balance = balance + 25 WHERE user_id = 1 RETURNING user_id, balance),
led AS (INSERT INTO ledger (user_id, amount, balance_after) SELECT user_id, 25, balance FROM upd)
SELECT user_id, balance FROM upd;
```
```
user_id | balance
--------+--------
1 | 125
```
- The data changes run first, in the order written. Then the main query runs.
- The statement is one transaction: when a part fails, nothing is written.
- Such a `SELECT` locks the tables it changes, as any writer does (chapter 15), and is never kept in the result cache.
## Part D: Correlated subqueries and LATERAL
A **correlated** subquery uses a column of the outer row.
```sql
SELECT t.ticket_id, t.title,
(SELECT count(*) FROM votes v WHERE v.ticket_id = t.ticket_id) AS votes,
EXISTS (SELECT 1 FROM votes v WHERE v.ticket_id = t.ticket_id AND v.user_id = 8) AS i_voted
FROM tickets t ORDER BY t.ticket_id;
```
```
ticket_id | title | votes | i_voted
----------+-----------------+-------+--------
1 | Dark mode | 2 | true
2 | Faster charts | 1 | false
3 | Nepali calendar | 0 | false
```
**How KDB keeps this fast.** It does not run the subquery once per outer row.
- When the subquery joins the outer row with `=` (`v.ticket_id = t.ticket_id`), KDB runs it **once** for all keys (grouped by `ticket_id`), puts the answers in a hash table, and every outer row looks its answer up. The work is one pass over `votes` plus one lookup per ticket.
- `count(*)` of a key without rows is 0, and `sum` is NULL, as in PostgreSQL.
- `EXISTS`, `NOT EXISTS`, `IN (SELECT ...)` and `NOT IN (SELECT ...)` work the same way (a set of keys).
- Other shapes (`v.amount > t.limit`) run once per **different** outer value, and the answers are kept for the statement.
- A subquery that uses no outer column runs once, before the query.
- A value subquery that finds two rows is an error, as in PostgreSQL. Add `LIMIT 1` or an aggregate.
`LATERAL` lets a `FROM` subquery use the tables before it:
```sql
SELECT t.title, last_vote.user_id
FROM tickets t
LEFT JOIN LATERAL (SELECT user_id FROM votes v WHERE v.ticket_id = t.ticket_id ORDER BY user_id DESC LIMIT 1) last_vote ON TRUE
ORDER BY t.ticket_id;
```
```
title | user_id
----------------+--------
Dark mode | 8
Faster charts | 7
Nepali calendar | NULL
```
A `LATERAL` subquery with an aggregate becomes a hash join; one with `ORDER BY ... LIMIT` runs per outer row (answers are kept for rows with the same key).
## Part E: More query forms
```sql
SELECT symbol,
count(*) FILTER (WHERE qty >= 10) AS big_trades,
string_agg(price::text, ' -> ' ORDER BY ts) AS path
FROM trades GROUP BY 1 ORDER BY 1;
```
```
symbol | big_trades | path
-------+------------+-----------
CITY | 2 | 510 -> 512
COAST | 0 | 900 -> 905
```
```sql
SELECT DISTINCT ON (symbol) symbol, ts, price FROM trades ORDER BY symbol, ts DESC; -- the newest trade of each symbol
```
```
symbol | ts | price
-------+--------------------------+------
CITY | 2026-09-10T05:20:00.000Z | 512
COAST | 2026-09-10T05:30:00.000Z | 905
```
| Form | Meaning |
|---|---|
| `agg(x) FILTER (WHERE cond)` | The aggregate only sees rows where `cond` is true. |
| `array_agg(x ORDER BY y)`, `string_agg(x, ',' ORDER BY y)` | The rows come in that order. |
| `DISTINCT ON (a) ... ORDER BY a, b` | The first row of each `a` in `ORDER BY` order. |
| `GROUP BY 1`, `ORDER BY 2` | Position of a select column. An alias works too: `GROUP BY day`. |
| `ORDER BY sum(x)`, `HAVING count(*) > 1` | Aggregates that are not select columns. |
| `NULLS FIRST`, `NULLS LAST` | NULL counts as bigger than every value (first with `DESC`), as in PostgreSQL. |
| `LIMIT ALL`, `OFFSET n ROWS`, `FETCH FIRST n ROWS ONLY` | Also QuestDB's `LIMIT lo, hi`. |
| `UNION`, `INTERSECT`, `EXCEPT` [`ALL`] | Without `ALL` the result has no copies. `INTERSECT` goes first. |
| `(VALUES (1, 'a'), (2, 'b')) AS v(n, s)` | A small table of values. |
| `(a, b) < (x, y)`, `(a, b) = (SELECT ...)` | Row comparisons. |
| `FROM (SELECT ...)` | The subquery needs no alias. |
## Part F: Casts, times and intervals
```sql
SELECT '12.5'::numeric(6,1) AS n, 2.5::int AS rounded, E'tab\there' AS e,
(DATE '2026-01-31' + INTERVAL '1 month')::date AS next_month;
```
```
n | rounded | e | next_month
-----+---------+----------+-----------
12.5 | 3 | tab here | 2026-02-28
```
- Every PostgreSQL type name works after `::` and in `CAST`: `int4`, `float8`, `numeric(10,2)`, `text`, `varchar(20)`, `timestamptz`, `interval`, `text[]`, `bigint[]`, ...
- A cast to a whole number or to `numeric(p,s)` rounds half away from zero. Other places (a value for an `INT` column, an argument of a saved function) still refuse `2.5`.
- `x::text` of a time gives PostgreSQL's text: `2026-09-10 12:00:00` (with `+00` for a `timestamptz`).
- A text constant next to a number is read as a number: `'1.50' + 1` is `2.50` (a numeric parameter sent as text works too). Text from a column or a function is not: `doc ->> 'n' + 1` is an error; write `(doc ->> 'n')::int + 1`.
- `md5(text)` and `initcap(text)` work as in PostgreSQL.
- `CURRENT_TIMESTAMP`, `CURRENT_DATE`, `LOCALTIMESTAMP`, `now()` are the time the statement started. KDB times are UTC.
- `DATE - DATE` is a number of days. A month added to the 31st ends on the last day of the next month.
**Intervals** keep months, days and time apart, as in PostgreSQL:
```sql
SELECT INTERVAL '1 day' - INTERVAL '1 hour' AS i, EXTRACT(EPOCH FROM INTERVAL '1 month') AS month_seconds,
make_interval(days => 3, hours => 2) AS m;
```
```
i | month_seconds | m
----------------+---------------+----------------
1 day -01:00:00 | 2592000 | 3 days 02:00:00
```
`justify_hours`, `justify_days` and `justify_interval` move 24 hours into a day and 30 days into a month. For comparing, a month counts as 30 days.
**AT TIME ZONE** follows PostgreSQL:
- `timestamp AT TIME ZONE z` reads a clock time of zone `z` and gives the instant (a `timestamptz`).
- `timestamptz AT TIME ZONE z` shows an instant as the clock time of zone `z` (a `timestamp`).
```sql
SELECT (TIMESTAMP '2026-09-10 12:00:00' AT TIME ZONE 'Asia/Kathmandu')::text AS utc_instant,
(TIMESTAMPTZ '2026-09-10 06:15:00+00' AT TIME ZONE 'Asia/Kathmandu')::text AS local_clock;
```
```
utc_instant | local_clock
-----------------------+--------------------
2026-09-10 06:15:00+00 | 2026-09-10 12:00:00
```
A `TIMESTAMP` column holds UTC instants in KDB, so KDB treats `now()`, `CURRENT_TIMESTAMP`, `timestamptz` casts and `TIMESTAMPTZ` columns as instants, and other `TIMESTAMP` values as clock times. Zones are fixed offsets (`'+05:45'`, `'UTC'`, `'Asia/Kathmandu'`, ...); zones with daylight saving time are refused.
## Part G: Arrays and JSON
KDB keeps an array as a JSON array. Over the PostgreSQL protocol it is a real array (`text[]` is type 1009, `bigint[]` 1016, ...) in text and binary form, so `pgx` scans it into a Go slice and sends Go slices as parameters.
```sql
CREATE TABLE feeds (id BIGINT PRIMARY KEY, tags TEXT[], roles JSONB);
INSERT INTO feeds VALUES (1, '{market,ipo}', '{"admin": "sita"}'), (2, ARRAY['hydro'], '{}');
SELECT id, unnest(tags) AS tag FROM feeds ORDER BY id, tag;
```
```
id | tag
---+-------
1 | ipo
1 | market
2 | hydro
```
| Form | Meaning |
|---|---|
| `x = ANY($1)`, `x <> ALL($1)` | `$1` is an array (a Go slice, `'{1,2}'`, or `ARRAY[...]`). A constant array becomes an `IN` list, so indexes and key ranges still work. |
| `ARRAY[1, 2]`, `ARRAY(SELECT ...)`, `'{a,b}'::text[]` | Make an array. |
| `tags[1]`, `cardinality(tags)`, `array_length(tags, 1)`, `array_to_string`, `string_to_array`, `array_append`, `array_remove`, `array_position` | Read and change arrays (from 1, as in PostgreSQL). |
| `array_agg(x)`, `(array_agg(x ORDER BY y))[1]` | Collect values. |
| `unnest(array)` | In `SELECT`: one row per element. In `FROM`: a table (`FROM unnest($1::int[]) AS u(id)`). |
| `tags @> ARRAY['a']`, `&&`, `<@` | Contains, overlaps, is contained. |
JSON functions: `jsonb_build_object`, `jsonb_build_array`, `json_agg` / `jsonb_agg` (with `ORDER BY`), `to_jsonb`, `row_to_json(t)`, `jsonb_set(doc, path, value, create)`, `jsonb_typeof`, `jsonb_array_length`, `->`, `->>`, `#>`, `#>>`.
```sql
UPDATE feeds SET roles = jsonb_set(roles, ARRAY['editor'], to_jsonb('ram'::text), true) WHERE id = 1 RETURNING roles;
```
```
roles
-------------------------------
{"admin":"sita","editor":"ram"}
```
JSON values sort like PostgreSQL's `jsonb`: numbers by value (9 before 10), strings by text, and null < strings < numbers < booleans < arrays < objects.
## Part H: Advisory locks
Advisory locks are named by a number (or two). KDB keeps them between sessions of one database.
| Function | Meaning |
|---|---|
| `pg_advisory_lock(key)` | Waits until no other session holds the key, then holds it until `pg_advisory_unlock` or the session ends. |
| `pg_try_advisory_lock(key)` | The same without waiting: `true` or `false`. |
| `pg_advisory_xact_lock(key)`, `pg_try_advisory_xact_lock(key)` | Held until `COMMIT` / `ROLLBACK` (or the end of the statement outside `BEGIN`). |
| `pg_advisory_unlock(key)`, `pg_advisory_unlock_all()` | Let it go. `unlock` returns `false` when the session did not hold it. |
| `..._shared` forms | Several sessions may share the key; an exclusive lock waits for all of them. |
- `(a, b)` with two `int` keys is another lock than one `bigint` key, as in PostgreSQL.
- A session may take its own lock again; it must unlock it as many times.
- Waiting ends after 30 seconds with an error (PostgreSQL waits forever).
- A statement alone outside a transaction waits without holding any table lock, so the session that holds the key can go on working. Inside `BEGIN ... COMMIT` it waits holding the locks of the tables the transaction used; keep such waits short.
- Over HTTP without a session, locks end with the request.
## Part I: QuestDB apps
**The dialect.** `ALTER DATABASE qdb SET dialect = 'questdb'` makes a database read SQL the QuestDB way:
- A plain `INSERT` into a table with a `DEDUP KEY` replaces the row with the same key (QuestDB's `DEDUP UPSERT KEYS`).
- `CAST(time AS LONG)` gives microseconds since 1970 (PostgreSQL mode: milliseconds).
`SET dialect = 'postgres'` goes back. The setting is kept in `_system` and applies every time the database opens (chapter 21 has the other `ALTER DATABASE` settings).
**CSV import.** `POST /imp` takes a CSV file the way QuestDB does (chapter 8 has the details):
```bash
curl -F "data=@Ochl_Day.csv" "http://127.0.0.1:9900/imp?name=Ochl_Day×tamp=time&partitionBy=DAY&fmt=json"
```
**Functions.** `to_str(time, 'yyyy-MM-dd HH:mm:ss')` formats a time with QuestDB's letters.
## What is different from PostgreSQL
- `WITH` queries that change data run before the main query and the main query sees their changes (PostgreSQL uses one snapshot). The app's statements give the same answers.
- A recursive `WITH` query stops with an error after 100,000 rounds or 10 million rows.
- Advisory locks wait at most 30 seconds.
- Time zones are fixed offsets; `AT TIME ZONE 'Europe/London'` is refused.
- A plain column name in `ON CONFLICT DO UPDATE SET` means the row that is there (PostgreSQL calls it ambiguous).
- Casting a whole number from `double precision` rounds half away from zero (PostgreSQL: to the nearest even number).
- Arrays have one dimension in binary form; nested arrays are sent as text.
- `refdata.companies` means the table `companies` of the **database** `refdata` on the same server (PostgreSQL would look for a schema `refdata`). You can read it in `SELECT`, joins, subqueries and `INSERT ... SELECT`. A statement changes only the database it runs in. `public.companies` is your own table. The help topic **Queries across databases** has the details.
## The app's queries on KDB
`tests/app_compat/run_compat.mjs` runs all 2143 statements of the app against a fresh server with sample data.
| Statements | HTTP before | HTTP now | PostgreSQL protocol before | now |
|---|---|---|---|---|
| All 2143 | 950 (44.3%) | 1971 (92.0%) | 930 (43.4%) | 1952 (91.1%) |
| SELECT (786) | 549 | 734 | 548 | 734 |
| WITH (20) | 0 | 15 | 0 | 15 |
| INSERT (209) | 26 | 142 | 26 | 142 |
| UPDATE (164) | 81 | 156 | 79 | 155 |
| DELETE (72) | 55 | 56 | 55 | 55 |
| Schema statements (889, chapter 22) | 238 | 866 | 221 | 849 |
"Now" is the run of 2026-09-23. Over the PostgreSQL protocol a few QuestDB `ALTER` and `RENAME` statements of the test fail because the test tool replays them in another order (the column or table is already there), and a few test values (`'test'` for a number) are refused earlier, when the parameters are read.
Most of the statements that still fail are not about query support:
- 44 "no unique key" for `ON CONFLICT`: the test schema (`schema_kdb.sql`) was written for an older KDB and left out composite keys. With the app's own `CREATE TABLE` they work.
- Sample values that do not fit (`'test'` for a number), and template parts the test cannot fill (`{{spec.conflict}}`).
- A few features of other chapters: QuestDB's `tables()` functions, and `COPY` sent over HTTP (over the PostgreSQL port `COPY` works, chapter 14).
## Speed
- A statement without the new forms runs the same code as before: the checks are a few flag tests.
- `ON CONFLICT` on the table's key with `DO NOTHING` or a full `DO UPDATE SET ... EXCLUDED` writes batches as fast as `INSERT OR REPLACE`.
- Correlated subqueries and `LATERAL` with `=` joins cost one pass over the inner table plus one hash lookup per outer row.
- A constant `= ANY(...)` becomes `IN (...)`: indexes, key ranges and the column engine still work.
- Queries with `FILTER` or `ORDER BY` inside an aggregate use the row engine.
---
# 24. Versions and upgrades
- Category: Tutorial
## What you will learn
KDB gets new features all the time. Some of them change what KDB writes on disk. This chapter explains what happens to your data when you update KDB, and when you go back to an older KDB:
- **Part A.** The rule: a new KDB opens old data; an old KDB refuses new data.
- **Part B.** `kdb version` and `SHOW VERSION`: which features a database uses.
- **Part C.** A database from an older KDB, and `kdb upgrade`.
- **Part D.** What an older KDB says, and what to do.
- **Part E.** How to update the KDB programs on a server.
All examples are in examples/24-versions-and-upgrades/. The output under each example is real. The examples use the database `market` with the sample data.
## The short version
- Update **every** `kdb` program (server, scripts, tools) at the same time, to the same build.
- A newer KDB opens your old databases as they are. There is nothing to convert.
- Before you update a server, make a backup (you do that every day anyway, chapter 9). `kdb upgrade ` does backup + feature list + check in one command, when you want it.
- If a `kdb` command says `this database needs a newer KDB` (exit code `3`): the data is **not damaged**. Use the newer KDB. Do **not** run `kdb repair` or `kdb restore`.
---
## Part A: the rule
Each database keeps a **feature list** in its manifest (the file that lists the database's files, chapter 9). A feature is one change to what KDB writes on disk. For example:
| Feature | The database has |
|---|---|
| `json` | a `JSON` column |
| `indexes` | a `CREATE INDEX` |
| `sequences` | a `CREATE SEQUENCE` |
| `audit` | a table with `SET AUDIT ON` |
| `runs` | rows written by the line protocol in the fast column form |
| `deferred_triggers` | a trigger made with `DEFERRED` (chapter 13) |
| `table_ttl` | a table with `TTL n DAYS` |
KDB puts a feature into the list **before** it writes the first data that needs it. When a database opens, KDB reads the list **first**, before it reads or changes anything else:
- Every name is known: the database opens.
- A name is not known: a newer KDB wrote this database. KDB stops with `this database needs a newer KDB: it uses ` and changes nothing.
The same message comes for the other things only a newer KDB writes: a newer file format number, a log record or catalog part KDB does not know. KDB never calls such data "damaged", and it never starts a repair.
Only schema statements (`CREATE`, `ALTER`, `DROP`), the first use of a feature and checkpoints look at the list. Writing rows costs nothing extra: the write benchmark gave the same numbers before and after (see the report `reports/versioning.md`).
## Part B: which features does a database use?
### kdb version
Without a database, `kdb version` shows this KDB and every feature it can read:
```powershell
kdb version
```
```text
kdb 0.2.0-dev (crc32c hardware: yes)
Stored-format features this build reads (26):
views 0.2.0-dev (2026-09-10) CREATE VIEW: the "views" file
partition_format_2 0.2.0-dev (2026-09-11) table partition files in format 2 (one raw block per column)
block_stats 0.2.0-dev (2026-09-11) column stats section in partition files (count, min, max, sum per column)
runs 0.2.0-dev (2026-09-11) log record 'C': rows of a bulk upsert kept as columns
auto_meta 0.2.0-dev (2026-09-11) AUTO id counters in the manifest (auto.)
json 0.2.0-dev (2026-09-11) JSON columns (type code 12)
bytes 0.2.0-dev (2026-09-11) BYTES columns (type code 13)
check 0.2.0-dev (2026-09-11) CHECK constraints (catalog format 2, part 'C')
indexes 0.2.0-dev (2026-09-11) CREATE INDEX, UNIQUE and FULLTEXT indexes (catalog part 'I')
routines 0.2.0-dev (2026-09-11) functions, procedures and triggers: the "routines" file
users 0.2.0-dev (2026-09-11) server users and roles (_system.users)
audit 0.2.0-dev (2026-09-12) SET AUDIT ON: catalog part 'A', log record 'P', the _audit_log table
table_grants 0.2.0-dev (2026-09-12) GRANT ... ON TABLE (_system.users.table_grants)
totp 0.2.0-dev (2026-09-12) two-factor logins (_system.users.totp)
trash 0.2.0-dev (2026-09-12) dropped tables kept as _dropped__ for UNDROP TABLE
database_settings 0.2.0-dev (2026-09-12) ALTER DATABASE ... SET READ ONLY (_system.database_settings)
dialects 0.2.0-dev (2026-09-12) ALTER DATABASE ... SET dialect (_system.database_dialects)
pg_catalog_parts 0.2.0-dev (2026-09-12) PostgreSQL column types, multi-column keys, foreign keys, expression and partial indexes (catalog parts 'T', 'K', 'F', 'X')
sequences 0.2.0-dev (2026-09-12) CREATE SEQUENCE: the "sequences" file and its log records
batched_log_sync 0.2.0-dev (2026-09-11) --sync-commits batch: log segments with 16-byte commit records
dedup_change 0.2.0-dev (2026-09-12) ALTER TABLE ... DEDUP ENABLE / DISABLE: the DEDUP KEY of a table that already holds rows
plpgsql_triggers 0.2.0-dev (2026-09-12) PostgreSQL plpgsql trigger functions and FOR EACH ROW triggers in the "routines" file
cached_views 0.2.0-dev (2026-09-13) CREATE CACHED VIEW: which views are cached, and REFRESH ON COMMIT, in the "views" file
deferred_triggers 0.2.0-dev (2026-09-13) CREATE TRIGGER ... DEFERRED: job records in the log, and the "trigger_queue" and "q/" files of queued jobs
materialized_views 0.2.0-dev (2026-09-13) CREATE MATERIALIZED VIEW: catalog part 'M', REFRESH MATERIALIZED VIEW log records, mv_dirty. in the manifest
table_ttl 0.2.0-dev (2026-09-13) TTL n DAYS on a table: catalog part 'L'
A database that uses a feature not in this list needs a newer KDB (kdb check exits with 3).
```
With a database, it shows the database's list. It reads only the manifest, so it also works while a server has the database open:
```powershell
kdb version market
```
```text
kdb 0.2.0-dev (crc32c hardware: yes)
database market
feature list (3):
batched_log_sync --sync-commits batch: log segments with 16-byte commit records
block_stats column stats section in partition files (count, min, max, sum per column)
partition_format_2 table partition files in format 2 (one raw block per column)
```
The sample data has plain tables only, so its list is short. `batched_log_sync` is in every list today: writers that commit at the same moment share one wait for the disk (group commit, chapter 15), and the log keeps them in the same form as `--sync-commits batch`. Each commit is still on the disk before it is confirmed. So every database this build writes uses `batched_log_sync`, and a KDB built before 2026-09-11 cannot open it.
### SHOW VERSION
In SQL (`kdb sql`, `kdb shell`, the web console, the PostgreSQL port), `SHOW VERSION` shows the same for the current database:
```sql
SHOW VERSION;
```
```text
item | value
------------------------+-------------------------------------------------
kdb version | 0.2.0-dev
database | market
features used | batched_log_sync, block_stats, partition_form...
feature list | saved: batched_log_sync, block_stats, partiti...
features this KDB reads | 26: views, partition_format_2, block_stats, r...
(5 rows, 0.1 ms)
```
- `features used`: what the database uses right now.
- `feature list`: what is saved in the manifest. Normally the same.
## Part C: a database from an older KDB
Databases made by KDB programs **before 2026-09-12** have no feature list. KDB opens them exactly as before. It works out their list from the tables, files and log, and saves it at the next checkpoint (for example when the database is closed).
The KDB source has small databases made by the older programs, for the tests (`tests/fixtures`). This one was made by the `kdb.exe` of 2026-09-11:
```powershell
# A database written by an older KDB (2026-09-11, before feature lists)
Copy-Item "$env:KDB_EXAMPLES\..\..\..\tests\fixtures\sep11_clean" old-db -Recurse
kdb version old-db
```
```text
kdb 0.2.0-dev (crc32c hardware: yes)
database old-db
feature list: not saved yet (the database was written by a KDB before feature lists).
It is saved at the next checkpoint of this KDB, or now with: kdb upgrade old-db
SHOW VERSION in kdb sql or kdb shell shows the features it uses.
```
### kdb upgrade
`kdb upgrade` does three things: a verified backup, then it saves the feature list, then `kdb check`. The database must be closed (stop the server first).
```powershell
kdb upgrade old-db --backups upgrade-backups
"exit code: $LASTEXITCODE"
kdb version old-db
kdb sql old-db "SELECT count(*) AS candles FROM ochl; SHOW VERSION"
```
```text
KDB 0.2.0-dev, database old-db
saved feature list: none (written by a KDB before feature lists)
1/3 verified backup into upgrade-backups ...
upgrade-backups/20260923-121111-373 (7 files, 10.5 KB)
2/3 saving the feature list ...
features used: batched_log_sync, block_stats, partition_format_2, views
(no files need to be written again for this KDB version)
3/3 check ...
checked 9 files (12.3 KB) and 2 log segments
OK: no damage found
Upgrade done. An older KDB build that does not know one of these features now refuses this database with "needs a newer KDB".
exit code: 0
kdb 0.2.0-dev (crc32c hardware: yes)
database old-db
feature list (4):
batched_log_sync --sync-commits batch: log segments with 16-byte commit records
block_stats column stats section in partition files (count, min, max, sum per column)
partition_format_2 table partition files in format 2 (one raw block per column)
views CREATE VIEW: the "views" file
candles
-------
176
(1 row, 0.7 ms)
item | value
------------------------+-------------------------------------------------
kdb version | 0.2.0-dev
database | old-db
features used | batched_log_sync, block_stats, partition_form...
feature list | saved: batched_log_sync, block_stats, partiti...
features this KDB reads | 26: views, partition_format_2, block_stats, r...
(5 rows, 0.1 ms)
```
- Without `--backups`, the backup goes into a folder next to the database: `old-db-backups` (for a database name in the data folder: `-backups/`). Older backups there are kept.
- You do not **have** to run `kdb upgrade`. It is the safe way when you want a backup from just before, or before you move the folder to another computer.
- Exit codes: `0` done, `3` the database needs a newer KDB (nothing done, no backup), `2` the check found damage (the backup is named), `1` could not run.
## Part D: when an older KDB opens newer data
To show it here, the environment variable `KDB_TEST_HIDE_FEATURES` makes `kdb` act like an older program that does not know `views`. It is **only for tests**; do not set it on a server.
```powershell
# Play an older KDB that does not know "views" (KDB_TEST_HIDE_FEATURES is only for tests)
$env:KDB_TEST_HIDE_FEATURES = "views"
kdb check old-db
"exit code: $LASTEXITCODE"
kdb sql old-db "SELECT count(*) FROM ochl"
"exit code: $LASTEXITCODE"
$env:KDB_TEST_HIDE_FEATURES = ""
kdb check old-db
"exit code: $LASTEXITCODE"
```
```text
error: this database needs a newer KDB: it uses views (a stored-format feature this KDB build does not know). Nothing was changed
This database was written by a newer KDB. Open it with that KDB or a newer one (kdb version lists what this build can read). Do not run kdb repair.
exit code: 3
error: this database needs a newer KDB: it uses views (a stored-format feature this KDB build does not know). Nothing was changed
This database was written by a newer KDB. Open it with that KDB or a newer one (kdb version lists what this build can read). Do not run kdb repair.
exit code: 3
checked 9 files (12.3 KB) and 2 log segments
OK: no damage found
exit code: 0
```
What to do when you see this message:
1. Do **not** repair or restore: the data is fine. `kdb repair` would refuse too (exit code `3`).
2. Find the newer `kdb` program that wrote the database, or build the newest one.
3. `kdb version` on both programs shows which features each one reads.
Where you see it:
| Where | What you get |
|---|---|
| `kdb check`, `kdb repair`, `kdb sql`, `kdb shell`, `kdb version