KDB knowledge

Sessions and cursors over HTTP

Keep a transaction open over several HTTP requests with a session, and read a big result a part at a time with a cursor.

Normally every HTTP request stands alone: a BEGIN in one /exec request cannot be finished by a COMMIT in the next one. Two tools help:

More about transactions: Transactions. SQL cursors inside procedures (DECLARE ... CURSOR): Cursors.

POST /session

Syntax:

POST /session

Opens a session and gives back its id. Send the id with every request that belongs to it:

Syntax:

X-KDB-Session: <id>        (a header on /exec)
/exec?session=<id>         (or a URL parameter or form field)
ParameterWhereMeaningDefault
(none)-The request has no body-
Answer fieldMeaning
sessionThe session id: 32 random hex characters. Keep it secret: it is like a key to the open transaction.
idle_timeout_msA session unused this long is closed, and its open transaction is rolled back (undone). 600,000 ms = 10 minutes.

HTTP: Without a session: BEGIN without COMMIT is rolled back (sample data: sample; shows an error)

POST /exec?compat=0

BEGIN;
INSERT INTO brokers VALUES (72, 'Never Saved Securities')
###
GET /exec?compat=0&query=SELECT count(*) AS saved FROM brokers WHERE id = 72

Response:

POST /exec?compat=0

HTTP 400 (application/json)

{
  "query": "BEGIN;\nINSERT INTO brokers VALUES (72, 'Never Saved Securities')",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 12, "execute_us": 5}
    },
    {
      "statement": "INSERT INTO brokers VALUES (72, 'Never Saved Securities')",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) inserted",
      "timings": {"parse_us": 12, "execute_us": 92}
    }
  ],
  "saved": 0,
  "rolled_back": true,
  "error": "BEGIN without COMMIT: the transaction was rolled back (end it with COMMIT to save the changes)",
  "sqlstate": "XX000"
}

GET /exec?compat=0&query=SELECT count(*) AS saved FROM brokers WHERE id = 72

HTTP 200 (application/json)

{
  "query": "SELECT count(*) AS saved FROM brokers WHERE id = 72",
  "results": [
    {
      "statement": "SELECT count(*) AS saved FROM brokers WHERE id = 72",
      "columns": [
        {"name": "saved", "type": "LONG"}
      ],
      "dataset": [
        [0]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 23, "execute_us": 116}
    }
  ],
  "timings": {"parse_us": 23, "execute_us": 116}
}

HTTP: A transaction over four requests (sample data: sample)

POST /session
###
POST /exec?compat=0
X-KDB-Session: {{session}}

BEGIN;
INSERT INTO brokers VALUES (70, 'Transaction Test Securities');
###
GET /exec?compat=0&query=SELECT count(*) AS others_see FROM brokers WHERE id = 70
###
POST /exec?compat=0
X-KDB-Session: {{session}}

COMMIT
###
GET /exec?compat=0&query=SELECT count(*) AS after_commit FROM brokers WHERE id = 70
###
DELETE /session/{{session}}

Response:

POST /session

HTTP 200 (application/json)

{"session": "26717468710339bbc51881cf94b08239", "idle_timeout_ms": 600000}

POST /exec?compat=0

HTTP 200 (application/json)

{
  "query": "BEGIN;\nINSERT INTO brokers VALUES (70, 'Transaction Test Securities');",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 9, "execute_us": 4}
    },
    {
      "statement": "INSERT INTO brokers VALUES (70, 'Transaction Test Securities')",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) inserted",
      "timings": {"parse_us": 9, "execute_us": 91}
    }
  ],
  "timings": {"parse_us": 18, "execute_us": 95}
}

GET /exec?compat=0&query=SELECT count(*) AS others_see FROM brokers WHERE id = 70

HTTP 200 (application/json)

{
  "query": "SELECT count(*) AS others_see FROM brokers WHERE id = 70",
  "results": [
    {
      "statement": "SELECT count(*) AS others_see FROM brokers WHERE id = 70",
      "columns": [
        {"name": "others_see", "type": "LONG"}
      ],
      "dataset": [
        [0]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 12, "execute_us": 77}
    }
  ],
  "timings": {"parse_us": 12, "execute_us": 77}
}

POST /exec?compat=0

HTTP 200 (application/json)

{
  "query": "COMMIT",
  "results": [
    {
      "statement": "COMMIT",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction committed: 1 change(s) saved",
      "timings": {"parse_us": 6, "execute_us": 518}
    }
  ],
  "timings": {"parse_us": 6, "execute_us": 518}
}

GET /exec?compat=0&query=SELECT count(*) AS after_commit FROM brokers WHERE id = 70

HTTP 200 (application/json)

{
  "query": "SELECT count(*) AS after_commit FROM brokers WHERE id = 70",
  "results": [
    {
      "statement": "SELECT count(*) AS after_commit FROM brokers WHERE id = 70",
      "columns": [
        {"name": "after_commit", "type": "LONG"}
      ],
      "dataset": [
        [1]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 21, "execute_us": 151}
    }
  ],
  "timings": {"parse_us": 21, "execute_us": 151}
}

DELETE /session/{{session}}

HTTP 200 (application/json)

{"session": "26717468710339bbc51881cf94b08239", "closed": true}

HTTP: ROLLBACK undoes the changes of the session (sample data: sample)

POST /session
###
POST /exec?compat=0
X-KDB-Session: {{session}}

BEGIN;
UPDATE ochl_day SET close = 0 WHERE symbol = 'CITY';
SELECT max(close) AS inside_the_transaction FROM ochl_day WHERE symbol = 'CITY'
###
POST /exec?compat=0&session={{session}}

ROLLBACK;
SELECT max(close) AS after_rollback FROM ochl_day WHERE symbol = 'CITY'
###
DELETE /session/{{session}}

Response:

POST /session

HTTP 200 (application/json)

{"session": "14c0655b56b5f4be70aa52b8c7ee5a2a", "idle_timeout_ms": 600000}

POST /exec?compat=0

HTTP 200 (application/json)

{
  "query": "BEGIN;\nUPDATE ochl_day SET close = 0 WHERE symbol = 'CITY';\nSELECT max(close) AS inside_the_transaction FROM ochl_day WHERE symbol = 'CITY'",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 13, "execute_us": 4}
    },
    {
      "statement": "UPDATE ochl_day SET close = 0 WHERE symbol = 'CITY'",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 19,
      "message": "19 row(s) updated",
      "timings": {"parse_us": 13, "execute_us": 224}
    },
    {
      "statement": "SELECT max(close) AS inside_the_transaction FROM ochl_day WHERE symbol = 'CITY'",
      "columns": [
        {"name": "inside_the_transaction", "type": "DOUBLE"}
      ],
      "dataset": [
        [0]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 13, "execute_us": 130}
    }
  ],
  "timings": {"parse_us": 39, "execute_us": 358}
}

POST /exec?compat=0&session={{session}}

HTTP 200 (application/json)

{
  "query": "ROLLBACK;\nSELECT max(close) AS after_rollback FROM ochl_day WHERE symbol = 'CITY'",
  "results": [
    {
      "statement": "ROLLBACK",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction rolled back: 1 change(s) undone",
      "timings": {"parse_us": 12, "execute_us": 27}
    },
    {
      "statement": "SELECT max(close) AS after_rollback FROM ochl_day WHERE symbol = 'CITY'",
      "columns": [
        {"name": "after_rollback", "type": "DOUBLE"}
      ],
      "dataset": [
        [542.4]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 12, "execute_us": 107}
    }
  ],
  "timings": {"parse_us": 24, "execute_us": 134}
}

DELETE /session/{{session}}

HTTP 200 (application/json)

{"session": "14c0655b56b5f4be70aa52b8c7ee5a2a", "closed": true}

A transfer between two accounts in Python, with the requests library:

import requests

KDB = "http://127.0.0.1:9900"

def run(sql, session=None):
    headers = {"Content-Type": "text/plain; charset=utf-8"}
    if session:
        headers["X-KDB-Session"] = session
    r = requests.post(f"{KDB}/exec", params={"compat": 0, "db": "shop"}, data=sql.encode("utf-8"), headers=headers)
    body = r.json()
    if r.status_code != 200:
        raise RuntimeError(f"HTTP {r.status_code}: {body['error']}")
    return body["results"]

session = requests.post(f"{KDB}/session").json()["session"]
try:
    run("BEGIN", session)
    run("UPDATE accounts SET balance = balance - 500 WHERE id = 1", session)
    balance = run("SELECT balance FROM accounts WHERE id = 1", session)[0]["dataset"][0][0]
    if float(balance) < 0:
        run("ROLLBACK", session)          # not enough money: undo
    else:
        run("UPDATE accounts SET balance = balance + 500 WHERE id = 2", session)
        run("COMMIT", session)
finally:
    requests.delete(f"{KDB}/session/{session}")   # rolls back anything still open
// Node 18+
const KDB = "http://127.0.0.1:9900";
const { session } = await (await fetch(`${KDB}/session`, { method: "POST" })).json();
const run = async (sql) => {
  const res = await fetch(`${KDB}/exec?compat=0`, { method: "POST", headers: { "X-KDB-Session": session }, body: sql });
  const body = await res.json();
  if (!res.ok) throw new Error(body.error);
  return body.results;
};
try {
  await run("BEGIN");
  await run("INSERT INTO orders VALUES (1, 'CITY', 10)");
  await run("COMMIT");
} finally {
  await fetch(`${KDB}/session/${session}`, { method: "DELETE" });
}
$kdb = "http://127.0.0.1:9900"
$s = (Invoke-RestMethod -Method Post "$kdb/session").session
$h = @{ "X-KDB-Session" = $s }
Invoke-RestMethod -Method Post "$kdb/exec?compat=0" -Headers $h -ContentType "text/plain" -Body "BEGIN; INSERT INTO brokers VALUES (71, 'Test')" | Out-Null
Invoke-RestMethod -Method Post "$kdb/exec?compat=0" -Headers $h -ContentType "text/plain" -Body "COMMIT" | Out-Null
Invoke-RestMethod -Method Delete "$kdb/session/$s" | Out-Null

DELETE /session/<id>

Syntax:

DELETE /session/<id>

Closes a session. Its open transaction is rolled back (nothing of it is saved) and its SQL cursors are closed.

ParameterWhereMeaningDefault
<id>pathThe id from POST /sessionrequired
Answer fieldMeaning
sessionThe id
closedtrue

HTTP: Closing the session undoes the open transaction (sample data: sample)

POST /session
###
POST /exec?compat=0
X-KDB-Session: {{session}}

BEGIN;
DELETE FROM ochl_day WHERE symbol = 'CITY'
###
DELETE /session/{{session}}
###
GET /exec?compat=0&query=SELECT count(*) AS city_days_still_there FROM ochl_day WHERE symbol = 'CITY'

Response:

POST /session

HTTP 200 (application/json)

{"session": "b8685fc0b19c2708dc998cf1b39aab61", "idle_timeout_ms": 600000}

POST /exec?compat=0

HTTP 200 (application/json)

{
  "query": "BEGIN;\nDELETE FROM ochl_day WHERE symbol = 'CITY'",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 143, "execute_us": 6}
    },
    {
      "statement": "DELETE FROM ochl_day WHERE symbol = 'CITY'",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 19,
      "message": "19 row(s) deleted",
      "timings": {"parse_us": 143, "execute_us": 352}
    }
  ],
  "timings": {"parse_us": 286, "execute_us": 358}
}

DELETE /session/{{session}}

HTTP 200 (application/json)

{"session": "b8685fc0b19c2708dc998cf1b39aab61", "closed": true}

GET /exec?compat=0&query=SELECT count(*) AS city_days_still_there FROM ochl_day WHERE symbol = 'CITY'

HTTP 200 (application/json)

{
  "query": "SELECT count(*) AS city_days_still_there FROM ochl_day WHERE symbol = 'CITY'",
  "results": [
    {
      "statement": "SELECT count(*) AS city_days_still_there FROM ochl_day WHERE symbol = 'CITY'",
      "columns": [
        {"name": "city_days_still_there", "type": "LONG"}
      ],
      "dataset": [
        [19]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 17, "execute_us": 185}
    }
  ],
  "timings": {"parse_us": 17, "execute_us": 185}
}

HTTP: A closed session cannot be used

POST /session
###
DELETE /session/{{session}}
###
POST /exec?compat=0
X-KDB-Session: {{session}}

SELECT 1

Response:

POST /session

HTTP 200 (application/json)

{"session": "8ebf095630c1793daccd6adfef334968", "idle_timeout_ms": 600000}

DELETE /session/{{session}}

HTTP 200 (application/json)

{"session": "8ebf095630c1793daccd6adfef334968", "closed": true}

POST /exec?compat=0

HTTP 404 (application/json)

{
  "error": "session '8ebf095630c1793daccd6adfef334968' does not exist: it was closed, or unused for more than 600 s (its open transaction was rolled back). Open a new one with POST /session"
}

POST /cursor

Syntax:

POST /cursor[?db=<name>]
body: one SELECT

Runs one SELECT, keeps all its rows on the server, and answers with the cursor's id. Read the rows with GET /cursor/&lt;id&gt;.

ParameterWhereMeaningDefault
bodybodyExactly one SELECT (or a form field query=)required
dbURLThe database (or the header X-KDB-Database). Use the same database for all requests of this cursor.the default database
Answer fieldMeaning
cursorThe cursor id (16 hex characters, hard to guess)
columns[]name and type of each column
rowsHow many rows the cursor holds

HTTP: Open a cursor, read 3 rows twice, close it (sample data: sample)

POST /cursor

SELECT time, symbol, close FROM ochl_minutes WHERE time IN '2026-09-10' ORDER BY time, symbol
###
GET /cursor/{{cursor}}?fetch=3
###
GET /cursor/{{cursor}}?fetch=3
###
DELETE /cursor/{{cursor}}

Response:

POST /cursor

HTTP 200 (application/json)

{
  "cursor": "5473dc90b23e81d5",
  "columns": [
    {"name": "time", "type": "TIMESTAMP"},
    {"name": "symbol", "type": "SYMBOL"},
    {"name": "close", "type": "DOUBLE"}
  ],
  "rows": 476
}

GET /cursor/{{cursor}}?fetch=3

HTTP 200 (application/json)

{
  "cursor": "5473dc90b23e81d5",
  "columns": [
    {"name": "time", "type": "TIMESTAMP"},
    {"name": "symbol", "type": "SYMBOL"},
    {"name": "close", "type": "DOUBLE"}
  ],
  "dataset": [
    ["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
}

GET /cursor/{{cursor}}?fetch=3

HTTP 200 (application/json)

{
  "cursor": "5473dc90b23e81d5",
  "columns": [
    {"name": "time", "type": "TIMESTAMP"},
    {"name": "symbol", "type": "SYMBOL"},
    {"name": "close", "type": "DOUBLE"}
  ],
  "dataset": [
    ["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
}

DELETE /cursor/{{cursor}}

HTTP 200 (application/json)

{"cursor": "5473dc90b23e81d5", "closed": true}

HTTP: A cursor needs exactly one SELECT (sample data: sample)

POST /cursor

DELETE FROM brokers WHERE id = 1

Response:

HTTP 400 (application/json)

{
  "query": "DELETE FROM brokers WHERE id = 1",
  "error": "a cursor needs exactly one SELECT statement",
  "sqlstate": "XX000"
}

GET /cursor/<id>

Syntax:

GET /cursor/<id>[?fetch=N]

Gives the next N rows of the cursor. It only reads rows that are already kept, so GET is right here.

ParameterWhereMeaningDefault
<id>pathThe id from POST /cursorrequired
fetchURLHow many rows: 1 or more, at most 1,000,0001,000
dbURLThe same database as POST /cursor (or the header X-KDB-Database)the default database
Answer fieldMeaning
cursorThe id
columns[]name and type of each column
datasetThe rows, in the same form as /exec
countNumber of rows in this answer
donetrue when no rows are left after these

HTTP: Read until done (sample data: sample)

POST /cursor

SELECT symbol, name FROM securities ORDER BY symbol
###
GET /cursor/{{cursor}}?fetch=2
###
GET /cursor/{{cursor}}?fetch=2
###
GET /cursor/{{cursor}}?fetch=2
###
DELETE /cursor/{{cursor}}

Response:

POST /cursor

HTTP 200 (application/json)

{
  "cursor": "8fe9f2c4b78babec",
  "columns": [
    {"name": "symbol", "type": "SYMBOL"},
    {"name": "name", "type": "STRING"}
  ],
  "rows": 5
}

GET /cursor/{{cursor}}?fetch=2

HTTP 200 (application/json)

{
  "cursor": "8fe9f2c4b78babec",
  "columns": [
    {"name": "symbol", "type": "SYMBOL"},
    {"name": "name", "type": "STRING"}
  ],
  "dataset": [
    ["ACME", "Acme Investment Company"],
    ["CITY", "City Bank Limited"]
  ],
  "count": 2,
  "done": false
}

GET /cursor/{{cursor}}?fetch=2

HTTP 200 (application/json)

{
  "cursor": "8fe9f2c4b78babec",
  "columns": [
    {"name": "symbol", "type": "SYMBOL"},
    {"name": "name", "type": "STRING"}
  ],
  "dataset": [
    ["COAST", "Coastal Bank Limited"],
    ["TELCO", "Northwind Telecom Limited"]
  ],
  "count": 2,
  "done": false
}

GET /cursor/{{cursor}}?fetch=2

HTTP 200 (application/json)

{
  "cursor": "8fe9f2c4b78babec",
  "columns": [
    {"name": "symbol", "type": "SYMBOL"},
    {"name": "name", "type": "STRING"}
  ],
  "dataset": [
    ["VOLT", "Volt River Hydropower Limited"]
  ],
  "count": 1,
  "done": true
}

DELETE /cursor/{{cursor}}

HTTP 200 (application/json)

{"cursor": "8fe9f2c4b78babec", "closed": true}

HTTP: fetch must be 1 or more (sample data: sample)

POST /cursor

SELECT symbol FROM securities
###
GET /cursor/{{cursor}}?fetch=0

Response:

POST /cursor

HTTP 200 (application/json)

{
  "cursor": "4f20a62d581e9021",
  "columns": [
    {"name": "symbol", "type": "SYMBOL"}
  ],
  "rows": 5
}

GET /cursor/{{cursor}}?fetch=0

HTTP 400 (application/json)

{"error": "fetch must be a number of rows, 1 or more"}

Reading a big result in Python, 5,000 rows at a time:

import requests

KDB = "http://127.0.0.1:9900"
params = {"db": "market"}
c = requests.post(f"{KDB}/cursor", params=params,
                  data="SELECT time, symbol, close FROM ochl_minutes ORDER BY time".encode("utf-8")).json()
if "error" in c:
    raise RuntimeError(c["error"])
print("rows:", c["rows"])
try:
    while True:
        page = requests.get(f"{KDB}/cursor/{c['cursor']}", params={**params, "fetch": 5000}).json()
        for time, symbol, close in page["dataset"]:
            pass  # use the row
        if page["done"]:
            break
finally:
    requests.delete(f"{KDB}/cursor/{c['cursor']}", params=params)
using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:9900") };
var open = await http.PostAsync("/cursor?db=market", new StringContent("SELECT time, close FROM ochl_minutes ORDER BY time", Encoding.UTF8, "text/plain"));
var cursor = JsonDocument.Parse(await open.Content.ReadAsStringAsync()).RootElement.GetProperty("cursor").GetString();
try
{
    while (true)
    {
        var page = JsonDocument.Parse(await http.GetStringAsync($"/cursor/{cursor}?db=market&fetch=5000")).RootElement;
        foreach (var row in page.GetProperty("dataset").EnumerateArray()) { /* use row[0], row[1] */ }
        if (page.GetProperty("done").GetBoolean()) break;
    }
}
finally
{
    await http.DeleteAsync($"/cursor/{cursor}?db=market");
}

DELETE /cursor/<id>

Syntax:

DELETE /cursor/<id>

Closes a cursor and frees its rows.

ParameterWhereMeaningDefault
<id>pathThe id from POST /cursorrequired
dbURLThe same database as POST /cursorthe default database
Answer fieldMeaning
cursorThe id
closedtrue

HTTP: After closing, the cursor is gone (sample data: sample)

POST /cursor

SELECT id, name FROM brokers
###
DELETE /cursor/{{cursor}}
###
GET /cursor/{{cursor}}

Response:

POST /cursor

HTTP 200 (application/json)

{
  "cursor": "ea3f12a0c29ef38e",
  "columns": [
    {"name": "id", "type": "INT"},
    {"name": "name", "type": "STRING"}
  ],
  "rows": 5
}

DELETE /cursor/{{cursor}}

HTTP 200 (application/json)

{"cursor": "ea3f12a0c29ef38e", "closed": true}

GET /cursor/{{cursor}}

HTTP 404 (application/json)

{"error": "cursor 'ea3f12a0c29ef38e' does not exist (an unused cursor closes after 10 minutes)"}