KDB knowledge

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.

/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).

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.

ParameterWhereMeaningDefault
queryURLThe SQL. One or more statements, split by ;. Encode spaces and ' in the URL (curl -G --data-urlencode does it).required
dbURLThe database. The header X-KDB-Database: <name> does the same.the default database
limitURLMost rows each statement gives back. 0 or less means 1,000,000. QuestDB style limit=10,500 uses the second number.10,000
compatURLcompat=0 (or false) leaves out the copy of the last result at the top level.1
cacheURLcache=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_msURLA 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
formatURLcolumns: every dataset as one array per column (format=columns)rows
nanURLstring: NaN, Infinity and -Infinity as the JSON strings "NaN", "Infinity", "-Infinity" instead of null (nan=string)null
sessionURLA session id from POST /session. The header X-KDB-Session: <id> does the same.none
If-None-MatchheaderThe ETag of an earlier answer: a 304 with no body while the rows are the same (ETag)none
Answer fieldMeaning
queryThe SQL you sent (only the first 4,096 bytes of a very long text)
results[]One object for each statement, in order
results[].statementThe statement, as KDB wrote it back
results[].columns[]name and type of each column (LONG, DOUBLE, TIMESTAMP, ...)
results[].datasetThe rows. Each row is a list of values in column order.
results[].countNumber of rows in dataset
results[].truncatedtrue when there were more rows than limit
results[].rows_affectedRows changed by INSERT, UPDATE or DELETE
results[].messageA short message, like 1 row(s) inserted
results[].timingsparse_us and execute_us: microseconds (millionths of a second) to read and to run the statement
columns, dataset, countA copy of the last result at the top level, as QuestDB answers. Not there with compat=0.
timingsThe sum of the timings of all statements

How values look in dataset:

KDB typeJSON 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)
TIMESTAMPUTC text: "2026-09-10T05:15:00.000Z"
DATEtext: "2026-09-10"
BOOLtrue or false
SYMBOL, STRINGtext
JSONthe 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 -Infinitynull, as QuestDB writes it; "NaN", "Infinity", "-Infinity" with nan=string

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.

ParameterWhereMeaningDefault
bodybodyThe SQL. Send it as plain text (Content-Type: text/plain; charset=utf-8), in UTF-8.required
queryform fieldThe SQL, when the body is a form. Without a query= field the whole body is the SQL.-
dbURL or form fieldThe database. Or the header X-KDB-Database.the default database
limitURL or form fieldMost rows per statement (as for GET)10,000
compatURL or form fieldcompat=0 leaves out the top-level copy1
cacheURLcache=0 skips the result cache (as for GET)1
formatURL or form fieldcolumns: one array per column (as for GET)rows
nanURLstring: NaN and the infinities as JSON strings (as for GET)null
sessionURL, form field or header X-KDB-SessionRun inside a session: BEGIN in one request, COMMIT in a later onenone
atomicURL, form field or header X-KDB-Atomic1: the whole body is one transaction, all or nothing (see atomic=1)0
Answer fieldMeaning
query, results[], columns, dataset, count, timingsas 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
WhatValue
HTTP statusnot 200: 400 for bad SQL, a table that already exists, a duplicate key, ...; the other codes above for other trouble
errorat the top level of the answer: what went wrong (with line and column when KDB knows them)
resultsonly the statements that ran before the failed one. The failed statement is not in results.
savedhow many of results are saved. Without BEGIN in the body: all of them.
rolled_backtrue 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 itdid not run

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}
}

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}
}

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

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.

CaseAnswer
all statements work200, the normal answer. Everything is saved together.
a statement failsnot 200, error, "saved": 0, "rolled_back": true. Nothing is saved.
the body has a statement that cannot run in a transaction400 before anything runs, "results": []

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.

ParameterWhereMeaningDefault
queryURLSELECT, SHOW or EXPLAINrequired
dbURLThe database (or the header X-KDB-Database)the default database
limitURLMost rows: any number, also above a million (limit=50000000 for a whole big table). 0 or less means 1,000,000.1,000,000
filenameURLThe name of the downloaded file. Only letters, digits, -, _ and . are kept; at most 100 characters.kdb-export.csv
AnswerMeaning
Content-Type: text/csv; charset=utf-8a CSV file in UTF-8
Content-Disposition: attachment; filename="..."tells a browser to save it with that name
X-KDB-Rowsthe rows in the file
X-KDB-Truncatedtrue when limit (or the default of 1,000,000) cut rows off: more rows exist. false when the file holds every row
first linethe column names
next linesone row each; lines end with CR LF (\r\n, as Windows and Excel expect)

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.

ParameterWhereMeaningDefault
bodybodySELECT, SHOW or EXPLAIN (or a form field query=)required
db, limit, filenameURL or form fieldas for GET /expas for GET /exp
AnswerMeaning
CSV fileas for GET /exp

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()));