Run SQL over HTTP (/exec, /exp)
Run SQL with GET or POST /exec and get JSON, or download the rows of a SELECT as CSV with /exp.
- Category: HTTP API
- Keywords: exec, /exec, exp, /exp, query, limit, compat, compat, cache, result cache, benchmark, SET result_cache, cache_now_ms, result_cache_now_ms, truncated, dataset, columns, count, timings, rows_affected, QuestDB, CSV export, download CSV, filename, Content-Disposition, form field, many statements, results array, error line column, --data-binary, --data-urlencode, NaN, nan, Infinity, timestamp as text, epoch milliseconds, statement failed, partial success, atomic, atomic, X-KDB-Atomic, all or nothing, rolled_back, saved, sqlstate, constraint, error code
- See also: HTTP API overview, Sessions and cursors over HTTP, Databases over HTTP (/api/databases), SELECT, Transactions, How KDB stores data
/exec runs SQL and answers with JSON. /exp runs a SELECT and answers with a CSV file (text with one row per line and commas between the values, which Excel opens).
GETonly runs SQL that reads.POSTruns any SQL.- Each request uses one database:
db=<name>or the headerX-KDB-Database(choosing the database). - The answer looks like QuestDB's
/exec, so QuestDB clients keep working (moving from QuestDB).
HTTP: Close prices of CITY (sample data: sample)
GET /exec?compat=0&query=SELECT time, close FROM ochl_day WHERE symbol = 'CITY' ORDER BY time LIMIT 3
Response:
HTTP 200 (application/json)
{
"query": "SELECT time, close FROM ochl_day WHERE symbol = 'CITY' ORDER BY time LIMIT 3",
"results": [
{
"statement": "SELECT time, close FROM ochl_day WHERE symbol = 'CITY' ORDER BY time LIMIT 3",
"columns": [
{"name": "time", "type": "TIMESTAMP"},
{"name": "close", "type": "DOUBLE"}
],
"dataset": [
["2025-12-28T00:00:00.000Z", 542.2],
["2025-12-29T00:00:00.000Z", 539.7],
["2025-12-30T00:00:00.000Z", 539.3]
],
"count": 3,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 29, "execute_us": 199}
}
],
"timings": {"parse_us": 29, "execute_us": 199}
}
GET /exec
Syntax:
GET /exec?query=<SQL>[&db=<name>][&limit=N][&compat=0][&cache=0][&format=rows|columns][&nan=null|string][&session=<id>]
Runs SQL that only reads (SELECT, SHOW ..., EXPLAIN, CHECK TABLE, USE) and answers with JSON. SQL that changes data gets 405: use POST /exec.
| Parameter | Where | Meaning | Default |
|---|---|---|---|
query | URL | The SQL. One or more statements, split by ;. Encode spaces and ' in the URL (curl -G --data-urlencode does it). | required |
db | URL | The database. The header X-KDB-Database: <name> does the same. | the default database |
limit | URL | Most rows each statement gives back. 0 or less means 1,000,000. QuestDB style limit=10,500 uses the second number. | 10,000 |
compat | URL | compat=0 (or false) leaves out the copy of the last result at the top level. | 1 |
cache | URL | cache=0 (or off, false) runs a SELECT fresh: it is not taken from the result cache and not kept there. Use it to time the engine in a benchmark. For a whole session: SET result_cache = off. A SELECT that must always be ready can be pinned. | 1 |
cache_now_ms | URL | A SELECT with now() (or current_date) may be answered by a kept result up to this old, like 500 or 2s. Without it such a SELECT is never kept. For a whole session: SET result_cache_now_ms = 500. | 0 |
format | URL | columns: every dataset as one array per column (format=columns) | rows |
nan | URL | string: NaN, Infinity and -Infinity as the JSON strings "NaN", "Infinity", "-Infinity" instead of null (nan=string) | null |
session | URL | A session id from POST /session. The header X-KDB-Session: <id> does the same. | none |
If-None-Match | header | The ETag of an earlier answer: a 304 with no body while the rows are the same (ETag) | none |
| Answer field | Meaning |
|---|---|
query | The SQL you sent (only the first 4,096 bytes of a very long text) |
results[] | One object for each statement, in order |
results[].statement | The statement, as KDB wrote it back |
results[].columns[] | name and type of each column (LONG, DOUBLE, TIMESTAMP, ...) |
results[].dataset | The rows. Each row is a list of values in column order. |
results[].count | Number of rows in dataset |
results[].truncated | true when there were more rows than limit |
results[].rows_affected | Rows changed by INSERT, UPDATE or DELETE |
results[].message | A short message, like 1 row(s) inserted |
results[].timings | parse_us and execute_us: microseconds (millionths of a second) to read and to run the statement |
columns, dataset, count | A copy of the last result at the top level, as QuestDB answers. Not there with compat=0. |
timings | The sum of the timings of all statements |
How values look in dataset:
| KDB type | JSON value |
|---|---|
INT, LONG, DOUBLE, ... | a number: 512.4 |
| a whole number bigger than 9,007,199,254,740,992 (2^53) | text: "9007199254740993", so JavaScript does not lose digits |
DECIMAL(s) | text with exactly s places: "1500.25" (it stays exact) |
TIMESTAMP | UTC text: "2026-09-10T05:15:00.000Z" |
DATE | text: "2026-09-10" |
BOOL | true or false |
SYMBOL, STRING | text |
JSON | the JSON itself (an object or a list), not a text |
NULL, and a number that is not real (like sqrt(-1)) | null |
a DOUBLE that is NaN, Infinity or -Infinity | null, as QuestDB writes it; "NaN", "Infinity", "-Infinity" with nan=string |
- Times are text, not numbers: every
TIMESTAMP, alsomin(time)andmax(time), comes as text like"2026-09-10T05:15:00.000Z". In JavaScriptNumber(value)givesNaN; useDate.parse(value)(milliseconds). Or ask SQL for a number:CAST(time AS LONG)gives milliseconds since 1970-01-01 UTC,date_part('epoch', time)gives seconds. - Status codes: 200 OK; 304 the
If-None-Matchtag still fits; 400 bad SQL, noquery, or aformatthat is notrowsorcolumns; 401/403 login or role; 404 unknown database or session; 405 SQL that changes data; 408 the statement ran past the statement timeout; 500 server trouble (damaged file); 503 the database is busy with another transaction, or the server is stopping. - Notes: GET must only read because a link or picture on another web page can make your browser send a GET.
limitcuts each statement's rows, so"truncated": truetells you more rows exist: use a biggerlimit, GET /exp or a cursor. - 10,000 rows: without
limit,/execgives back at most 10,000 rows per statement, so a forgottenWHEREcannot send millions of rows to a browser. Ask for more withlimit=(up to 1,000,000).
HTTP: The QuestDB shape: the last result also at the top level (sample data: sample)
GET /exec?query=SELECT symbol, sector FROM securities ORDER BY symbol LIMIT 2
Response:
HTTP 200 (application/json)
{
"query": "SELECT symbol, sector FROM securities ORDER BY symbol LIMIT 2",
"results": [
{
"statement": "SELECT symbol, sector FROM securities ORDER BY symbol LIMIT 2",
"columns": [
{"name": "symbol", "type": "SYMBOL"},
{"name": "sector", "type": "STRING"}
],
"dataset": [
["ACME", "Investment"],
["CITY", "Banking"]
],
"count": 2,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 18, "execute_us": 158}
}
],
"columns": [
{"name": "symbol", "type": "SYMBOL"},
{"name": "sector", "type": "STRING"}
],
"dataset": [
["ACME", "Investment"],
["CITY", "Banking"]
],
"count": 2,
"timings": {"parse_us": 18, "execute_us": 158}
}
HTTP: limit=2 and truncated (sample data: sample)
GET /exec?compat=0&limit=2&query=SELECT time, close FROM ochl_minutes WHERE symbol = 'CITY' ORDER BY time
Response:
HTTP 200 (application/json)
{
"query": "SELECT time, close FROM ochl_minutes WHERE symbol = 'CITY' ORDER BY time",
"results": [
{
"statement": "SELECT time, close FROM ochl_minutes WHERE symbol = 'CITY' ORDER BY time",
"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": 24, "execute_us": 400}
}
],
"timings": {"parse_us": 24, "execute_us": 400}
}
HTTP: cache=0: computed now, not taken from the result cache (sample data: sample)
GET /exec?compat=0&cache=0&query=SELECT symbol, count(*) FROM ochl_day GROUP BY symbol ORDER BY symbol LIMIT 3
Response:
HTTP 200 (application/json)
{
"query": "SELECT symbol, count(*) FROM ochl_day GROUP BY symbol ORDER BY symbol LIMIT 3",
"results": [
{
"statement": "SELECT symbol, count(*) FROM ochl_day GROUP BY symbol ORDER BY symbol LIMIT 3",
"columns": [
{"name": "symbol", "type": "SYMBOL"},
{"name": "count(*)", "type": "LONG"}
],
"dataset": [
["CITY", 19],
["COAST", 19],
["VOLT", 19]
],
"count": 3,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 36, "execute_us": 511}
}
],
"timings": {"parse_us": 36, "execute_us": 511}
}
HTTP: How each type looks in JSON
GET /exec?compat=0&query=SELECT CAST('2026-09-10T05:15:00Z' AS TIMESTAMP) AS ts, CAST('2026-09-10' AS DATE) AS day, CAST(1500.25 AS DECIMAL(2)) AS price, 9007199254740993 AS big, true AS ok, NULL AS nothing, 'नेपाल' AS text, sqrt(-1) AS not_real
Response:
HTTP 200 (application/json)
{
"query": "SELECT CAST('2026-09-10T05:15:00Z' AS TIMESTAMP) AS ts, CAST('2026-09-10' AS DATE) AS day, CAST(1500.25 AS DECIMAL(2)) AS price, 9007199254740993 AS big, true AS ok, NULL AS nothing, 'नेपाल' AS text, sqrt(-1) AS not_real",
"results": [
{
"statement": "SELECT CAST('2026-09-10T05:15:00Z' AS TIMESTAMP) AS ts, CAST('2026-09-10' AS DATE) AS day, CAST(1500.25 AS DECIMAL(2)) AS price, 9007199254740993 AS big, TRUE AS ok, NULL AS nothing, 'नेपाल' AS text, sqrt(-1) AS not_real",
"columns": [
{"name": "ts", "type": "TIMESTAMP"},
{"name": "day", "type": "DATE"},
{"name": "price", "type": "DECIMAL(2)"},
{"name": "big", "type": "LONG"},
{"name": "ok", "type": "BOOL"},
{"name": "nothing", "type": "STRING"},
{"name": "text", "type": "STRING"},
{"name": "not_real", "type": "STRING"}
],
"dataset": [
["2026-09-10T05:15:00.000Z", "2026-09-10", "1500.25", "9007199254740993", true, null, "नेपाल", null]
],
"count": 1,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 38, "execute_us": 75}
}
],
"timings": {"parse_us": 38, "execute_us": 75}
}
HTTP: The first time as text and as numbers (sample data: sample)
GET /exec?compat=0&query=SELECT min(time) AS first, CAST(min(time) AS LONG) AS first_ms, date_part('epoch', min(time)) AS first_seconds FROM ochl_day WHERE symbol = 'CITY'
Response:
HTTP 200 (application/json)
{
"query": "SELECT min(time) AS first, CAST(min(time) AS LONG) AS first_ms, date_part('epoch', min(time)) AS first_seconds FROM ochl_day WHERE symbol = 'CITY'",
"results": [
{
"statement": "SELECT min(time) AS first, CAST(min(time) AS LONG) AS first_ms, date_part('epoch', min(time)) AS first_seconds FROM ochl_day WHERE symbol = 'CITY'",
"columns": [
{"name": "first", "type": "TIMESTAMP"},
{"name": "first_ms", "type": "LONG"},
{"name": "first_seconds", "type": "DOUBLE"}
],
"dataset": [
["2025-12-28T00:00:00.000Z", 1766880000000, 1766880000]
],
"count": 1,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 56, "execute_us": 410}
}
],
"timings": {"parse_us": 56, "execute_us": 410}
}
HTTP: SQL that changes data needs POST (sample data: sample)
GET /exec?query=UPDATE ochl_day SET close = 0 WHERE symbol = 'CITY'
Response:
HTTP 405 (application/json)
{"error": "this SQL changes data, so it must be sent with POST /exec (GET /exec only reads)"}
HTTP: No query
GET /exec
Response:
HTTP 400 (application/json)
{"error": "missing query: use /exec?query=<SQL> or POST the SQL as the body"}
With curl, let it encode the SQL (-G puts the data into the URL):
curl -s -G "http://127.0.0.1:9900/exec" --data-urlencode "query=SELECT symbol, close FROM ochl_day WHERE symbol = 'CITY' LIMIT 2" --data-urlencode "compat=0"
$q = [uri]::EscapeDataString("SELECT symbol, close FROM ochl_day WHERE symbol = 'CITY' LIMIT 2")
$r = Invoke-RestMethod "http://127.0.0.1:9900/exec?compat=0&query=$q"
$r.results[0].dataset | ForEach-Object { $_ -join " | " }
POST /exec
Syntax:
POST /exec[?db=<name>][&limit=N][&compat=0][&cache=0][&format=rows|columns][&nan=null|string][&session=<id>][&atomic=1]
body: the SQL text
or a form (Content-Type: application/x-www-form-urlencoded) with the field query=<SQL>
Runs any SQL: INSERT, UPDATE, CREATE TABLE, CALL, BEGIN, many statements at once. The answer has the same fields as GET /exec.
| Parameter | Where | Meaning | Default |
|---|---|---|---|
| body | body | The SQL. Send it as plain text (Content-Type: text/plain; charset=utf-8), in UTF-8. | required |
query | form field | The SQL, when the body is a form. Without a query= field the whole body is the SQL. | - |
db | URL or form field | The database. Or the header X-KDB-Database. | the default database |
limit | URL or form field | Most rows per statement (as for GET) | 10,000 |
compat | URL or form field | compat=0 leaves out the top-level copy | 1 |
cache | URL | cache=0 skips the result cache (as for GET) | 1 |
format | URL or form field | columns: one array per column (as for GET) | rows |
nan | URL | string: NaN and the infinities as JSON strings (as for GET) | null |
session | URL, form field or header X-KDB-Session | Run inside a session: BEGIN in one request, COMMIT in a later one | none |
atomic | URL, form field or header X-KDB-Atomic | 1: the whole body is one transaction, all or nothing (see atomic=1) | 0 |
| Answer field | Meaning |
|---|---|
query, results[], columns, dataset, count, timings | as in GET /exec |
error | (only on an error) what went wrong |
sqlstate | (only on an error) the same five-character code the PostgreSQL port sends: 23505 duplicate key, 23503 foreign key, 42P01 unknown table, the code of a trigger's RAISE ... USING ERRCODE, ... |
table, constraint, column_name | (only on a 23505 or 23503 error) the table and constraint it was about, like users and users_email_key, and the column when the constraint has one |
line, column | (only on an error, when KDB knows it) where in the text you sent. Counted in characters, from 1. |
saved | (only on an error) how many entries of results, from the first, are saved. The same with or without compat=0. |
rolled_back | (only on an error) true when a transaction was rolled back by the error: the entries after saved ran but are not saved |
- Status codes: 200 OK; 304 (as for GET); 400 bad SQL (syntax, unknown column, duplicate key, ...) or a damaged compressed body; 401/403 login or role; 404 unknown database or session; 408 statement timeout; 413 body over 64 MB (counted after unpacking); 500 server trouble; 503 database busy, the server is stopping, or the system was short of memory for a moment (send it again; a 500 that says
applied in memorymust not be sent again); 507 the disk is full (the write was not saved; it works again once space is freed). - Compressed body: a body sent with
Content-Encoding: gzipordeflateis unpacked before it is read. Handy for a longINSERTscript. - Many statements: they run one after the other. Each one is saved when it is done (unless you opened a transaction with
BEGIN). When one statement fails:
| What | Value |
|---|---|
| HTTP status | not 200: 400 for bad SQL, a table that already exists, a duplicate key, ...; the other codes above for other trouble |
error | at the top level of the answer: what went wrong (with line and column when KDB knows them) |
results | only the statements that ran before the failed one. The failed statement is not in results. |
saved | how many of results are saved. Without BEGIN in the body: all of them. |
rolled_back | true when the body had a BEGIN without COMMIT before the error (or atomic=1, or a session's open transaction): those statements were undone |
| statements after it | did not run |
- Check the status code or
errorfirst. A client that only readsresultssees the statements that worked and can think a failedCREATE TABLEworked too. - Read
saved, not the length ofresults, to know what is really in the database. - A syntax error in any statement is found before anything runs: status 400,
resultsis empty, and nothing is saved, not even the statements before it. - Notes:
curl --datasends the body labelled as a form; that still works, because a form without aquery=field is taken as the SQL. Use--data-binaryso curl does not change line breaks. To send a.sqlfile with text in any language (Nepali, Chinese, ...), send the file's bytes. A body can be up to 64 MB (a big batch of INSERTs); for fast loading also see the line protocol.
HTTP: Two statements: an INSERT and a SELECT (sample data: sample)
POST /exec?compat=0
Content-Type: text/plain; charset=utf-8
INSERT INTO brokers VALUES (99, 'Himalayan Securities');
SELECT id, name FROM brokers WHERE id >= 58 ORDER BY id
Response:
HTTP 200 (application/json)
{
"query": "INSERT INTO brokers VALUES (99, 'Himalayan Securities');\nSELECT id, name FROM brokers WHERE id >= 58 ORDER BY id",
"results": [
{
"statement": "INSERT INTO brokers VALUES (99, 'Himalayan Securities')",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 1,
"message": "1 row(s) inserted",
"timings": {"parse_us": 18, "execute_us": 521}
},
{
"statement": "SELECT id, name FROM brokers WHERE id >= 58 ORDER BY id",
"columns": [
{"name": "id", "type": "INT"},
{"name": "name", "type": "STRING"}
],
"dataset": [
[58, "Northgate Securities"],
[99, "Himalayan Securities"]
],
"count": 2,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 18, "execute_us": 259}
}
],
"timings": {"parse_us": 36, "execute_us": 780}
}
HTTP: The second INSERT fails: the first one ran and is kept (sample data: sample; shows an error)
POST /exec?compat=0
INSERT INTO brokers VALUES (98, 'Everest Capital');
INSERT INTO brokers VALUES (98, 'Everest Capital again');
SELECT count(*) FROM brokers
###
GET /exec?compat=0&query=SELECT id, name FROM brokers WHERE id = 98
Response:
POST /exec?compat=0
HTTP 400 (application/json)
{
"query": "INSERT INTO brokers VALUES (98, 'Everest Capital');\nINSERT INTO brokers VALUES (98, 'Everest Capital again');\nSELECT count(*) FROM brokers",
"results": [
{
"statement": "INSERT INTO brokers VALUES (98, 'Everest Capital')",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 1,
"message": "1 row(s) inserted",
"timings": {"parse_us": 11, "execute_us": 501}
}
],
"saved": 1,
"rolled_back": false,
"error": "a row with id='98' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it",
"sqlstate": "23505",
"table": "brokers",
"constraint": "brokers_pkey",
"column_name": "id"
}
GET /exec?compat=0&query=SELECT id, name FROM brokers WHERE id = 98
HTTP 200 (application/json)
{
"query": "SELECT id, name FROM brokers WHERE id = 98",
"results": [
{
"statement": "SELECT id, name FROM brokers WHERE id = 98",
"columns": [
{"name": "id", "type": "INT"},
{"name": "name", "type": "STRING"}
],
"dataset": [
[98, "Everest Capital"]
],
"count": 1,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 12, "execute_us": 62}
}
],
"timings": {"parse_us": 12, "execute_us": 62}
}
HTTP: A failed CREATE TABLE: status 400, the first two statements are kept, the last did not run
POST /exec?compat=0
CREATE TABLE notes (id LONG PRIMARY KEY, body STRING);
INSERT INTO notes VALUES (1, 'first');
CREATE TABLE notes (id LONG);
INSERT INTO notes VALUES (2, 'second')
Response:
HTTP 400 (application/json)
{
"query": "CREATE TABLE notes (id LONG PRIMARY KEY, body STRING);\nINSERT INTO notes VALUES (1, 'first');\nCREATE TABLE notes (id LONG);\nINSERT INTO notes VALUES (2, 'second')",
"results": [
{
"statement": "CREATE TABLE notes (\n id LONG PRIMARY KEY,\n body STRING\n)",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 0,
"message": "table 'notes' created",
"timings": {"parse_us": 8, "execute_us": 23337}
},
{
"statement": "INSERT INTO notes VALUES (1, 'first')",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 1,
"message": "1 row(s) inserted",
"timings": {"parse_us": 8, "execute_us": 3187}
}
],
"saved": 2,
"rolled_back": false,
"error": "table 'notes' already exists; expected a new table name, or CREATE TABLE IF NOT EXISTS",
"sqlstate": "42P07"
}
HTTP: A syntax error in the second statement: nothing runs, not even the first (sample data: sample; shows an error)
POST /exec?compat=0
INSERT INTO brokers VALUES (97, 'Test Securities');
SELEC count(*) FROM brokers
###
GET /exec?compat=0&query=SELECT count(*) AS rows_with_id_97 FROM brokers WHERE id = 97
Response:
POST /exec?compat=0
HTTP 400 (application/json)
{
"query": "INSERT INTO brokers VALUES (97, 'Test Securities');\nSELEC count(*) FROM brokers",
"results": [],
"saved": 0,
"rolled_back": false,
"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'",
"sqlstate": "42601",
"line": 2,
"column": 1
}
GET /exec?compat=0&query=SELECT count(*) AS rows_with_id_97 FROM brokers WHERE id = 97
HTTP 200 (application/json)
{
"query": "SELECT count(*) AS rows_with_id_97 FROM brokers WHERE id = 97",
"results": [
{
"statement": "SELECT count(*) AS rows_with_id_97 FROM brokers WHERE id = 97",
"columns": [
{"name": "rows_with_id_97", "type": "LONG"}
],
"dataset": [
[0]
],
"count": 1,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 16, "execute_us": 127}
}
],
"timings": {"parse_us": 16, "execute_us": 127}
}
HTTP: BEGIN in the body: the error rolls back what came after it (sample data: sample; shows an error)
POST /exec?compat=0
INSERT INTO brokers VALUES (95, 'Peak Securities');
BEGIN;
INSERT INTO brokers VALUES (94, 'Valley Capital');
INSERT INTO brokers VALUES (94, 'Valley Capital again')
###
GET /exec?compat=0&query=SELECT id FROM brokers WHERE id IN (94, 95)
Response:
POST /exec?compat=0
HTTP 400 (application/json)
{
"query": "INSERT INTO brokers VALUES (95, 'Peak Securities');\nBEGIN;\nINSERT INTO brokers VALUES (94, 'Valley Capital');\nINSERT INTO brokers VALUES (94, 'Valley Capital again')",
"results": [
{
"statement": "INSERT INTO brokers VALUES (95, 'Peak Securities')",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 1,
"message": "1 row(s) inserted",
"timings": {"parse_us": 10, "execute_us": 708}
},
{
"statement": "BEGIN",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 0,
"message": "transaction started",
"timings": {"parse_us": 10, "execute_us": 5}
},
{
"statement": "INSERT INTO brokers VALUES (94, 'Valley Capital')",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 1,
"message": "1 row(s) inserted",
"timings": {"parse_us": 10, "execute_us": 35}
}
],
"saved": 1,
"rolled_back": true,
"error": "a row with id='94' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it (the transaction was rolled back)",
"sqlstate": "23505",
"table": "brokers",
"constraint": "brokers_pkey",
"column_name": "id"
}
GET /exec?compat=0&query=SELECT id FROM brokers WHERE id IN (94, 95)
HTTP 200 (application/json)
{
"query": "SELECT id FROM brokers WHERE id IN (94, 95)",
"results": [
{
"statement": "SELECT id FROM brokers WHERE id IN (94, 95)",
"columns": [
{"name": "id", "type": "INT"}
],
"dataset": [
[95]
],
"count": 1,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 10, "execute_us": 75}
}
],
"timings": {"parse_us": 10, "execute_us": 75}
}
One array per column: format=columns
?format=columns writes every dataset as an object with one array per column instead of an array of rows:
HTTP: format=columns: one array per column (sample data: sample)
GET /exec?compat=0&format=columns&query=SELECT symbol, close FROM ochl_day WHERE symbol = 'CITY' ORDER BY time LIMIT 3
Response:
HTTP 200 (application/json)
{
"query": "SELECT symbol, close FROM ochl_day WHERE symbol = 'CITY' ORDER BY time LIMIT 3",
"results": [
{
"statement": "SELECT symbol, close FROM ochl_day WHERE symbol = 'CITY' ORDER BY time LIMIT 3",
"columns": [
{"name": "symbol", "type": "SYMBOL"},
{"name": "close", "type": "DOUBLE"}
],
"dataset": {
"symbol": ["CITY", "CITY", "CITY"],
"close": [542.2, 539.7, 539.3]
},
"count": 3,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 27, "execute_us": 230}
}
],
"timings": {"parse_us": 27, "execute_us": 230}
}
- A chart, a spreadsheet or a columnar file gets the shape it stores, with no reshaping on the client.
- The cells are the same as in the row form: times as ISO strings, JSON columns as JSON,
nullfor NULL. - Two result columns with one name: the later one wins (an object holds one of each name). Give them aliases.
format=rowsis the default. Anything else is a 400.
NaN and Infinity: nan=string
JSON has no numbers for NaN, Infinity and -Infinity. By default /exec writes them as null, as QuestDB's /exec does, so an old client reads them as it always did. A null there cannot be told from a real NULL, though: with ?nan=string they come as the strings "NaN", "Infinity" and "-Infinity" (the spelling PostgreSQL and JavaScript use; Number("NaN") is NaN).
HTTP: The same row, both ways
GET /exec?compat=0&query=SELECT 'NaN'::float8 AS nan, 'Infinity'::float8 AS inf, NULL::float8 AS nothing, 1.5::float8 AS number
###
GET /exec?compat=0&nan=string&query=SELECT 'NaN'::float8 AS nan, 'Infinity'::float8 AS inf, NULL::float8 AS nothing, 1.5::float8 AS number
Response:
GET /exec?compat=0&query=SELECT 'NaN'::float8 AS nan, 'Infinity'::float8 AS inf, NULL::float8 AS nothing, 1.5::float8 AS number
HTTP 200 (application/json)
{
"query": "SELECT 'NaN'::float8 AS nan, 'Infinity'::float8 AS inf, NULL::float8 AS nothing, 1.5::float8 AS number",
"results": [
{
"statement": "SELECT CAST('NaN' AS DOUBLE) AS nan, CAST('Infinity' AS DOUBLE) AS inf, CAST(NULL AS DOUBLE) AS nothing, CAST(1.5 AS DOUBLE) AS number",
"columns": [
{"name": "nan", "type": "DOUBLE"},
{"name": "inf", "type": "DOUBLE"},
{"name": "nothing", "type": "STRING"},
{"name": "number", "type": "DOUBLE"}
],
"dataset": [
[null, null, null, 1.5]
],
"count": 1,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 36, "execute_us": 40}
}
],
"timings": {"parse_us": 36, "execute_us": 40}
}
GET /exec?compat=0&nan=string&query=SELECT 'NaN'::float8 AS nan, 'Infinity'::float8 AS inf, NULL::float8 AS nothing, 1.5::float8 AS number
HTTP 200 (application/json)
{
"query": "SELECT 'NaN'::float8 AS nan, 'Infinity'::float8 AS inf, NULL::float8 AS nothing, 1.5::float8 AS number",
"results": [
{
"statement": "SELECT CAST('NaN' AS DOUBLE) AS nan, CAST('Infinity' AS DOUBLE) AS inf, CAST(NULL AS DOUBLE) AS nothing, CAST(1.5 AS DOUBLE) AS number",
"columns": [
{"name": "nan", "type": "DOUBLE"},
{"name": "inf", "type": "DOUBLE"},
{"name": "nothing", "type": "STRING"},
{"name": "number", "type": "DOUBLE"}
],
"dataset": [
["NaN", "Infinity", null, 1.5]
],
"count": 1,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 4, "execute_us": 42}
}
],
"timings": {"parse_us": 4, "execute_us": 42}
}
- Only
DOUBLEandFLOATvalues change;NULLstaysnull. nan=nullis the default. Anything else is a 400.- The PostgreSQL port always sends
NaN,Infinityand-Infinity.
Nothing to send: ETag and If-None-Match
Every /exec reply that carries a dataset names it in an ETag header - a checksum of the dataset's bytes, its size and its shape. A client that keeps the last answer sends the tag back:
POST /exec?compat=0
If-None-Match: "102625ff-1bcr"
SELECT time, close FROM ochl_day WHERE symbol = 'CITY' ORDER BY time
HTTP/1.1 304 Not Modified
- While the data is the same the answer is a 304 with no body: a page that refreshes every few seconds costs the query, not the bytes.
- The query still runs (or comes from the result cache); only the sending is saved.
- The tag changes as soon as any row the query reads changes, because it is made from the answer itself; with the result cache on, a repeated query with the same tag is answered from memory.
- The row and column shapes have different tags.
All or nothing: atomic=1
With atomic=1 (or the header X-KDB-Atomic: 1) the whole body runs as one transaction. When any statement fails, every statement of the body is rolled back.
| Case | Answer |
|---|---|
| all statements work | 200, the normal answer. Everything is saved together. |
| a statement fails | not 200, error, "saved": 0, "rolled_back": true. Nothing is saved. |
| the body has a statement that cannot run in a transaction | 400 before anything runs, "results": [] |
- Allowed:
SELECT,SHOW,EXPLAIN,INSERT,UPDATE,DELETE,CALL, cursors,CHECK TABLE. - Refused:
CREATE,DROP,ALTER,RENAME,REFRESH VIEW,BACKUP DATABASE,USE,SET, users and grants (KDB cannot roll these back), andBEGIN,COMMIT,ROLLBACK(the body is already a transaction). The message isatomic=1: CREATE TABLE cannot run inside a transaction .... atomic=1cannot be used with a session (X-KDB-Session): there, sendBEGIN...COMMITyourself.lineandcolumnof an error still count in the text you sent.- Other writers wait until the body is done, as with
BEGIN...COMMIT. Keep atomic bodies short.
HTTP: atomic=1: the second INSERT fails, so the first one is rolled back too (sample data: sample; shows an error)
POST /exec?compat=0&atomic=1
INSERT INTO brokers VALUES (96, 'Summit Capital');
INSERT INTO brokers VALUES (96, 'Summit Capital again')
###
GET /exec?compat=0&query=SELECT count(*) AS rows_with_id_96 FROM brokers WHERE id = 96
Response:
POST /exec?compat=0&atomic=1
HTTP 400 (application/json)
{
"query": "INSERT INTO brokers VALUES (96, 'Summit Capital');\nINSERT INTO brokers VALUES (96, 'Summit Capital again')",
"results": [
{
"statement": "INSERT INTO brokers VALUES (96, 'Summit Capital')",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 1,
"message": "1 row(s) inserted",
"timings": {"parse_us": 2, "execute_us": 112}
}
],
"saved": 0,
"rolled_back": true,
"error": "a row with id='96' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it (the transaction was rolled back)",
"sqlstate": "23505",
"table": "brokers",
"constraint": "brokers_pkey",
"column_name": "id"
}
GET /exec?compat=0&query=SELECT count(*) AS rows_with_id_96 FROM brokers WHERE id = 96
HTTP 200 (application/json)
{
"query": "SELECT count(*) AS rows_with_id_96 FROM brokers WHERE id = 96",
"results": [
{
"statement": "SELECT count(*) AS rows_with_id_96 FROM brokers WHERE id = 96",
"columns": [
{"name": "rows_with_id_96", "type": "LONG"}
],
"dataset": [
[0]
],
"count": 1,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 13, "execute_us": 68}
}
],
"timings": {"parse_us": 13, "execute_us": 68}
}
HTTP: atomic=1 with statements that all work (sample data: sample)
POST /exec?compat=0&atomic=1
INSERT INTO brokers VALUES (93, 'Delta Securities');
UPDATE brokers SET name = 'Delta Securities Ltd' WHERE id = 93;
SELECT id, name FROM brokers WHERE id = 93
Response:
HTTP 200 (application/json)
{
"query": "INSERT INTO brokers VALUES (93, 'Delta Securities');\nUPDATE brokers SET name = 'Delta Securities Ltd' WHERE id = 93;\nSELECT id, name FROM brokers WHERE id = 93",
"results": [
{
"statement": "INSERT INTO brokers VALUES (93, 'Delta Securities')",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 1,
"message": "1 row(s) inserted",
"timings": {"parse_us": 4, "execute_us": 126}
},
{
"statement": "UPDATE brokers SET name = 'Delta Securities Ltd' WHERE id = 93",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 1,
"message": "1 row(s) updated",
"timings": {"parse_us": 4, "execute_us": 51}
},
{
"statement": "SELECT id, name FROM brokers WHERE id = 93",
"columns": [
{"name": "id", "type": "INT"},
{"name": "name", "type": "STRING"}
],
"dataset": [
[93, "Delta Securities Ltd"]
],
"count": 1,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 4, "execute_us": 38}
}
],
"timings": {"parse_us": 12, "execute_us": 215}
}
HTTP: atomic=1 refuses CREATE TABLE before anything runs
POST /exec?compat=0&atomic=1
CREATE TABLE notes (id LONG PRIMARY KEY);
INSERT INTO notes VALUES (1)
Response:
HTTP 400 (application/json)
{
"query": "CREATE TABLE notes (id LONG PRIMARY KEY);\nINSERT INTO notes VALUES (1)",
"results": [],
"saved": 0,
"rolled_back": false,
"error": "atomic=1: CREATE TABLE cannot run inside a transaction (a rollback cannot undo it), so nothing was run; send it in a request without atomic=1",
"sqlstate": "XX000"
}
HTTP: A form field query= (what an HTML form or curl --data-urlencode sends) (sample data: sample)
POST /exec
Content-Type: application/x-www-form-urlencoded
compat=0&limit=3&query=SELECT symbol, ltp FROM live_data LATEST ON timestamp PARTITION BY symbol ORDER BY symbol
Response:
HTTP 200 (application/json)
{
"query": "SELECT symbol, ltp FROM live_data LATEST ON timestamp PARTITION BY symbol ORDER BY symbol",
"results": [
{
"statement": "SELECT symbol, ltp FROM live_data LATEST ON timestamp PARTITION BY symbol ORDER BY symbol",
"columns": [
{"name": "symbol", "type": "SYMBOL"},
{"name": "ltp", "type": "DOUBLE"}
],
"dataset": [
["CITY", 527.1],
["COAST", 401.9],
["TELCO", 897.7]
],
"count": 3,
"truncated": true,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 33, "execute_us": 259}
}
],
"timings": {"parse_us": 33, "execute_us": 259}
}
HTTP: Create a table and write rows in one request
POST /exec?compat=0
CREATE TABLE ticks (time TIMESTAMP, symbol SYMBOL, price DOUBLE) TIMESERIES(time) PARTITION BY DAY;
INSERT INTO ticks VALUES ('2026-09-10T05:15:00Z', 'CITY', 512.4), ('2026-09-10T05:15:01Z', 'COAST', 380.1);
SELECT * FROM ticks
Response:
HTTP 200 (application/json)
{
"query": "CREATE TABLE ticks (time TIMESTAMP, symbol SYMBOL, price DOUBLE) TIMESERIES(time) PARTITION BY DAY;\nINSERT INTO ticks VALUES ('2026-09-10T05:15:00Z', 'CITY', 512.4), ('2026-09-10T05:15:01Z', 'COAST', 380.1);\nSELECT * FROM ticks",
"results": [
{
"statement": "CREATE TABLE ticks (\n time TIMESTAMP,\n symbol SYMBOL,\n price DOUBLE\n) TIMESERIES(time) PARTITION BY DAY",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 0,
"message": "table 'ticks' created",
"timings": {"parse_us": 15, "execute_us": 16474}
},
{
"statement": "INSERT INTO ticks VALUES ('2026-09-10T05:15:00Z', 'CITY', 512.4), ('2026-09-10T05:15:01Z', 'COAST', 380.1)",
"columns": [],
"dataset": [],
"count": 0,
"truncated": false,
"rows_affected": 2,
"message": "2 row(s) inserted",
"timings": {"parse_us": 15, "execute_us": 1930}
},
{
"statement": "SELECT * FROM ticks",
"columns": [
{"name": "time", "type": "TIMESTAMP"},
{"name": "symbol", "type": "SYMBOL"},
{"name": "price", "type": "DOUBLE"}
],
"dataset": [
["2026-09-10T05:15:00.000Z", "CITY", 512.4],
["2026-09-10T05:15:01.000Z", "COAST", 380.1]
],
"count": 2,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 15, "execute_us": 78}
}
],
"timings": {"parse_us": 45, "execute_us": 18482}
}
Clients (not run by the help build; chapter 8 has tested ones with real output):
curl -s "http://127.0.0.1:9900/exec?compat=0" --data-binary "INSERT INTO brokers VALUES (99, 'Himalayan Securities')"
curl -s "http://127.0.0.1:9900/exec?compat=0" -H "Content-Type: text/plain; charset=utf-8" --data-binary @load.sql
$body = [System.IO.File]::ReadAllBytes((Resolve-Path load.sql)) # the bytes: no encoding is lost
$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 }
import requests
def kdb(sql, db=None, limit=10000):
params = {"compat": 0, "limit": limit}
if db:
params["db"] = db
r = requests.post("http://127.0.0.1:9900/exec", params=params, data=sql.encode("utf-8"),
headers={"Content-Type": "text/plain; charset=utf-8"})
body = r.json()
if r.status_code != 200:
raise RuntimeError(f"KDB error (HTTP {r.status_code}): {body['error']}")
return body["results"]
res = kdb("SELECT symbol, close FROM ochl_day WHERE symbol = 'CITY' LIMIT 3")[0]
names = [c["name"] for c in res["columns"]]
for row in res["dataset"]:
print(dict(zip(names, row)))
async function kdb(sql, db) {
const url = `http://127.0.0.1:9900/exec?compat=0${db ? `&db=${encodeURIComponent(db)}` : ""}`;
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "text/plain; charset=utf-8" }, 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, close FROM ochl_day LIMIT 3");
console.log(res.dataset);
static readonly HttpClient Http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:9900") };
static async Task<JsonElement> Kdb(string sql)
{
var content = new StringContent(sql, Encoding.UTF8, "text/plain");
using var res = await Http.PostAsync("/exec?compat=0", content);
var json = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement.Clone();
if (!res.IsSuccessStatusCode)
throw new Exception($"KDB error (HTTP {(int)res.StatusCode}): {json.GetProperty("error").GetString()}");
return json.GetProperty("results");
}
GET /exp
Syntax:
GET /exp?query=<SQL>[&db=<name>][&limit=N][&filename=<name.csv>]
Runs SQL that reads and answers with the rows of the last statement as a CSV file. A browser downloads it.
| Parameter | Where | Meaning | Default |
|---|---|---|---|
query | URL | SELECT, SHOW or EXPLAIN | required |
db | URL | The database (or the header X-KDB-Database) | the default database |
limit | URL | Most rows: any number, also above a million (limit=50000000 for a whole big table). 0 or less means 1,000,000. | 1,000,000 |
filename | URL | The name of the downloaded file. Only letters, digits, -, _ and . are kept; at most 100 characters. | kdb-export.csv |
| Answer | Meaning |
|---|---|
Content-Type: text/csv; charset=utf-8 | a CSV file in UTF-8 |
Content-Disposition: attachment; filename="..." | tells a browser to save it with that name |
X-KDB-Rows | the rows in the file |
X-KDB-Truncated | true when limit (or the default of 1,000,000) cut rows off: more rows exist. false when the file holds every row |
| first line | the column names |
| next lines | one row each; lines end with CR LF (\r\n, as Windows and Excel expect) |
- Status codes: 200 OK; 400 no
query, bad SQL, or SQL that is not aSELECT,SHOWorEXPLAIN; 401/403 login or role; 404 unknown database. - Big exports are streamed. One
SELECTof the columns of one table (with aWHERE, noORDER BY,GROUP BYor join) is sent while it is read, so the server never holds the whole file: millions of rows go out at a few million rows a second, beyondmax_result_memory. Once the file passes about 16 MB the answer is sent withTransfer-Encoding: chunked, andX-KDB-RowsandX-KDB-Truncatedcome at its end as trailer fields (Trailer: X-KDB-Rows, X-KDB-Truncated;curl -D -shows them after the body). A smaller file has them as headers. Other queries (ORDER BY,GROUP BY, joins, several statements) are made whole first, within the memory limits. - An error part way through a streamed export (the client left, the server stops) ends the connection without the last chunk, so the client sees a cut-off download, never a short file that looks whole.
- Time: a streamed export is one statement until its last row is sent, so the statement timeout (
kdb serve --statement-timeout, 60 s by default) counts the sending too. For a very big table over a slow link, raise it or export by time ranges. - Notes: NULL is an empty field. Text with a comma, a quote, a line break, or a space at the start or end is put in
"..."(a"inside becomes""). Times are UTC like2026-09-10T00:00:00.000Z. Errors are JSON (query,error,line,column), not CSV./expaccepts only reading SQL even with POST, and does not use sessions.
HTTP: Daily closes as CSV, with a file name (sample data: sample)
GET /exp?filename=closes-2026-09-10.csv&query=SELECT time, symbol, close FROM ochl_day WHERE time IN '2026-09-10' ORDER BY symbol
Response:
HTTP 200 (text/csv)
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
HTTP: Text with commas and quotes, and NULL
GET /exp?query=SELECT 'City Bank, Kathmandu' AS name, 'the "best" bank' AS note, NULL AS empty, 512.4 AS price
Response:
HTTP 200 (text/csv)
name,note,empty,price
"City Bank, Kathmandu","the ""best"" bank",,512.4
HTTP: Only SQL that reads can be exported (sample data: sample)
GET /exp?query=DELETE FROM brokers
Response:
HTTP 400 (application/json)
{"error": "CSV export needs a SELECT, SHOW or EXPLAIN statement"}
curl -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
$q = [uri]::EscapeDataString("SELECT * FROM ochl_day WHERE symbol = 'COAST' ORDER BY time")
Invoke-WebRequest "http://127.0.0.1:9900/exp?query=$q" -OutFile coast.csv
Import-Csv coast.csv | Select-Object -First 3
import requests
r = requests.get("http://127.0.0.1:9900/exp",
params={"query": "SELECT * FROM ochl_day WHERE symbol = 'COAST' ORDER BY time"})
r.raise_for_status()
open("coast.csv", "wb").write(r.content)
POST /exp
Syntax:
POST /exp[?db=<name>][&limit=N][&filename=<name.csv>]
body: the SQL text, or a form with the field query=<SQL>
The same as GET /exp, with the SQL in the body. Use it when the query is too long for a URL.
| Parameter | Where | Meaning | Default |
|---|---|---|---|
| body | body | SELECT, SHOW or EXPLAIN (or a form field query=) | required |
db, limit, filename | URL or form field | as for GET /exp | as for GET /exp |
| Answer | Meaning |
|---|---|
| CSV file | as for GET /exp |
- Status codes: 200 OK; 400 no SQL, bad SQL, or SQL that changes data; 401/403 login or role; 404 unknown database.
- Notes: POST does not allow changes here:
/exponly exports.
HTTP: A long SELECT in the body (sample data: sample)
POST /exp?filename=volume.csv
SELECT symbol, sum(quantity) AS shares, round(sum(amount), 2) AS amount
FROM trade_log
GROUP BY symbol
ORDER BY shares DESC
Response:
HTTP 200 (text/csv)
symbol,shares,amount
COAST,3070,1227510
TELCO,2770,2438260
CITY,2420,1247230
VOLT,400,73950
// Node 18+: save a CSV export.
import fs from "node:fs";
const res = await fetch("http://127.0.0.1:9900/exp?filename=volume.csv", {
method: "POST",
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: "SELECT symbol, sum(quantity) AS shares FROM trade_log GROUP BY symbol",
});
if (!res.ok) throw new Error((await res.json()).error);
fs.writeFileSync("volume.csv", Buffer.from(await res.arrayBuffer()));