KDB knowledge

8. HTTP API

What you will learn

Start the server first (with a backup folder, for the backup routes). kdb serve market serves the one database folder market; Many databases shows a server for a whole data folder.

kdb serve market --backups backups

All examples use http://127.0.0.1:9900. On Windows, write curl.exe (in PowerShell, curl alone is another command).

Routes

MethodPathWhat it doesLogin needed
GET/healthIs the server alive? Version, clock.no
POST/api/loginLog in with a user name and password: a cookie and a token (chapter 16)no
POST/api/logoutEnd the loginno
GET/api/meThe logged-in user and roleyes
GET/exec?query=SQLRun SQL that reads. JSON result.yes
POST/execRun any SQL (the body is the SQL). JSON result.yes
DELETE/exec/<id>Stop the running request sent with X-KDB-Request-Id: <id> (Stop a slow request)yes
GET or POST/exp?query=SQLRun a SELECT and download CSVyes
POST/imp?name=tableLoad a CSV file into a table, as QuestDB does (/imp)yes
GET/api/infoTables, columns, sizes, damage, last backupyes
POST/api/checkRead every file and the log; report damageyes
POST/api/backupMake a verified backup (needs --backups)yes
GET/api/backupsList backups, newest firstyes
GET/api/audit?limit=N&where=...The audit log: logins, user changes, DDL, backups (server admins; chapter 21). kind=data reads the data-change rows of the databaseyes
GET/api/databasesList the databases: name, folder, tables, bytesyes
POST/api/databasesCreate a database (name=<name> or {"name":"<name>"})yes
DELETE/api/databases/<name>Drop a database (its folder moves to _trash)yes
POST/cursorRun one SELECT and keep its rows (see Cursors)yes
GET/cursor/<id>?fetch=NThe next N kept rowsyes
DELETE/cursor/<id>Close the cursoryes
POST/session, DELETE /session/<id>A transaction over many requests: send X-KDB-Session: <id> with each /exec (chapter 15)yes
POST/write (also /api/v2/write)Load rows in the InfluxDB line format, the fast way for feeds and sensors (Line protocol)yes
GET/api/statsMemory, processor, running statements, open databases (the console's Monitor)yes
GET/api/diskWhat a database takes on disk, part by partyes
GET/api/activityRecent server events (the console's Terminal)yes
GET/api/trigger-queueThe queues of deferred triggers (chapter 13)yes
GET, POST/api/timersSQL that runs on a schedule (admins)yes
GET, POST, DELETE/api/pinsStatements whose results are kept ready (admins; chapter 17)yes
GET, POST/api/settings, POST /api/restartSaved settings in kdb.conf (the console's Settings page); restart in place (admins)yes
GET/The web console (chapter 6), from the folder web/ next to kdbno
GET/help/The help pages, from the folder help/ next to kdbno
GET/skill.mdThe Claude skill file, from the folder skill/ next to kdbno

"Login needed" applies when the server was started with --token, or when its data folder has users (chapter 16). Then each user may only do what their role allows; other requests get HTTP 403.

Every route that reads or changes data (all except /health and /api/databases) works on one database: the one named by the parameter db=<name> or the header X-KDB-Database: <name>. Without them, the server's default database is used. With kdb serve market the server has only that database, so you can leave db out. See Many databases.

Parameters for /exec and /exp:

ParameterMeaningDefault
dbThe database (also for /api/info, /api/check, /api/backup, /api/backups, /write)the default database
queryThe SQL (for GET, or as a form field in POST)-
limitMost rows per statement. At most 1,000,000. limit=0 means 1,000,000. QuestDB style limit=10,500 uses the second number./exec: 10,000; /exp: 1,000,000
compatcompat=0 leaves out the QuestDB-style copy of the last result (see below)1
format(/exec only) columns: each dataset as one list per column (format=columns)rows
atomic(/exec only) atomic=1 (or the header X-KDB-Atomic: 1): all statements of the body are one transaction, all or nothing (atomic=1)0
cachecache=0: do not use the result cache for this request (for timing tests)1
filename(/exp only) name of the downloaded filekdb-export.csv

GET /health

curl.exe -s http://127.0.0.1:9900/health
{"status":"ok","version":"0.2.0-dev","server_time_ms":1789114666217,"auth_required":false,"login":false}

server_time_ms is the server clock in milliseconds since 1970 (UTC). auth_required is true when requests need a token or a login. login is true when the data folder has users (the console then shows a login form). Use /health for monitoring: it never needs a token and never touches the data.

/exec: run SQL

GET only reads

Use --data-urlencode so curl makes the URL safe (spaces, quotes, %):

curl.exe -s -G http://127.0.0.1:9900/exec --data-urlencode "query=SELECT id, name FROM brokers WHERE id < 40"

The answer (formatted for reading; the server sends it on one line):

{
  "query": "SELECT id, name FROM brokers WHERE id < 40",
  "results": [
    {
      "statement": "SELECT id, name FROM brokers WHERE id < 40",
      "columns": [ { "name": "id", "type": "INT" }, { "name": "name", "type": "STRING" } ],
      "dataset": [ [1, "Keystone Securities"], [34, "Vision Securities"] ],
      "count": 2,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": { "parse_us": 7, "execute_us": 167 }
    }
  ],
  "columns": [ { "name": "id", "type": "INT" }, { "name": "name", "type": "STRING" } ],
  "dataset": [ [1, "Keystone Securities"], [34, "Vision Securities"] ],
  "count": 2,
  "timings": { "parse_us": 7, "execute_us": 167 }
}
FieldMeaning
queryThe SQL you sent
resultsOne object per statement
results[].statementThe statement, as KDB understood it
results[].columnsName and type of each column
results[].datasetThe rows. Each row is a list of values in column order.
results[].countNumber of rows in dataset
results[].truncatedtrue if there were more rows than limit
results[].rows_affectedRows changed by INSERT, UPDATE or DELETE
results[].messageFor example 3 row(s) inserted
results[].timingsMicroseconds for parsing and running
columns, dataset, count (top level)A copy of the last result, like QuestDB's /exec. Left out with compat=0.

Values: numbers are JSON numbers; TIMESTAMP and DATE are UTC text like "2026-09-09T05:15:00.000Z"; DECIMAL is text ("1500.25"), so it stays exact; BOOL is true/false; NULL is null.

POST runs anything

Send the SQL as the request body. --data-binary sends it unchanged.

curl.exe -s http://127.0.0.1:9900/exec --data-binary "SELECT symbol, ltp FROM live_data LATEST ON timestamp PARTITION BY symbol LIMIT 2"
{"query":"SELECT symbol, ltp FROM live_data LATEST ON timestamp PARTITION BY symbol LIMIT 2","results":[{"statement":"SELECT symbol, ltp FROM live_data LATEST ON timestamp PARTITION BY symbol LIMIT 2","columns":[{"name":"symbol","type":"SYMBOL"},{"name":"ltp","type":"DOUBLE"}],"dataset":[["CITY",527.1],["COAST",401.9]],"count":2,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":35,"execute_us":150}}],"columns":[{"name":"symbol","type":"SYMBOL"},{"name":"ltp","type":"DOUBLE"}],"dataset":[["CITY",527.1],["COAST",401.9]],"count":2,"timings":{"parse_us":35,"execute_us":150}}

The body can also be a form: query=<SQL> with Content-Type: application/x-www-form-urlencoded.

Many statements in one request, with compat=0:

curl.exe -s "http://127.0.0.1:9900/exec?compat=0" --data-binary "INSERT INTO brokers VALUES (21, 'Test Broker'); SELECT count(*) AS brokers FROM brokers"
{
  "query": "INSERT INTO brokers VALUES (21, 'Test Broker'); SELECT count(*) AS brokers FROM brokers",
  "results": [
    {
      "statement": "INSERT INTO brokers VALUES (21, 'Test Broker')",
      "columns": [], "dataset": [], "count": 0, "truncated": false,
      "rows_affected": 1, "message": "1 row(s) inserted",
      "timings": { "parse_us": 13, "execute_us": 3454 }
    },
    {
      "statement": "SELECT count(*) AS brokers FROM brokers",
      "columns": [ { "name": "brokers", "type": "LONG" } ],
      "dataset": [ [6] ], "count": 1, "truncated": false,
      "rows_affected": 0, "message": "",
      "timings": { "parse_us": 13, "execute_us": 30 }
    }
  ],
  "timings": { "parse_us": 26, "execute_us": 3484 }
}

(formatted for reading)

The GET rule

GET /exec only runs statements that read: SELECT, SHOW ..., EXPLAIN, CHECK TABLE, USE. Everything that changes data (INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, RENAME, CREATE VIEW, DROP VIEW, BACKUP DATABASE, CREATE DATABASE, DROP DATABASE) must use POST.

Why: a link or an image on any web page can make your browser send a GET request. With this rule, such a page cannot change your data.

# GET /exec only reads. SQL that changes data must use POST.
curl.exe -s -w "   (HTTP %{http_code})" -G http://127.0.0.1:9900/exec --data-urlencode "query=DELETE FROM brokers WHERE id = 21"
""
# The same SQL with POST works:
curl.exe -s "http://127.0.0.1:9900/exec?compat=0" --data-binary "DELETE FROM brokers WHERE id = 21"
{"error":"this SQL changes data, so it must be sent with POST /exec (GET /exec only reads)"}   (HTTP 405)
{"query":"DELETE FROM brokers WHERE id = 21","results":[{"statement":"DELETE FROM brokers WHERE id = 21","columns":[],"dataset":[],"count":0,"truncated":false,"rows_affected":1,"message":"1 row(s) deleted","timings":{"parse_us":15,"execute_us":4637}}],"timings":{"parse_us":15,"execute_us":4637}}

(-w "(HTTP %{http_code})" makes curl print the HTTP status after the body.)

Errors

A bad statement gets HTTP 400 and an error. When KDB knows the place, line and column are separate fields:

curl.exe -s -w "\nHTTP status: %{http_code}\n" http://127.0.0.1:9900/exec --data-binary "SELECT clsoe FROM ochl_day"
{"query":"SELECT clsoe FROM ochl_day","results":[],"saved":0,"rolled_back":false,"error":"line 1, column 8: unknown column 'clsoe' (table 'ochl_day' has: time symbol open high low close volume)","line":1,"column":8}
HTTP status: 400
HTTP statusMeaning
200OK
400The SQL or the request is wrong (syntax, duplicate key, missing query, ...)
401Token missing or wrong
403Refused for safety: cross-site request, a host name that is not allowed, missing rights, or a read-only database
404No such route, or no such database (db=)
409The database already exists, or it is in use and cannot be dropped now
405GET /exec with SQL that changes data
408The request did not arrive within 30 seconds, or the statement was stopped: statement timeout, DELETE /exec/<id>, server shutdown
413Request body bigger than 64 MB, or the query keeps too much in memory (--max-result-memory, --max-groups)
429Too many failed logins, or the user already runs --max-queries-per-user statements
431Request headers bigger than 64 KB
500Problem on the server: a damaged file, or the database is locked by another program
503Busy: a table stayed locked by another transaction too long (table 'x' is busy), a deadlock, or the server is stopping. Try again a moment later.
507The disk is full. Nothing of the request was saved. It works again once space is freed.
304Not changed: the If-None-Match tag is still the same (ETag). No body.

With many statements, results holds the statements that ran before the error. The statements after the error did not run. Two fields say what was kept:

FieldMeaning
savedHow many of the statements in results are saved.
rolled_backtrue when the error undid an open transaction (or an atomic=1 request): then nothing of it is saved, saved is 0.

atomic=1: all or nothing

Normally each statement of a request is saved on its own. With atomic=1, the whole body is one transaction: when one statement fails, the ones before it are undone too.

# atomic=1: the whole body is one transaction. The second INSERT fails, so the first is undone too.
curl.exe -s -w "   (HTTP %{http_code})" "http://127.0.0.1:9900/exec?atomic=1&compat=0" --data-binary "INSERT INTO brokers VALUES (22, 'New Broker'); INSERT INTO brokers VALUES (1, 'Keystone again')"
""
curl.exe -s -G http://127.0.0.1:9900/exec --data-urlencode "query=SELECT count(*) AS brokers FROM brokers WHERE id = 22" --data-urlencode "compat=0"
{"query":"INSERT INTO brokers VALUES (22, 'New Broker'); INSERT INTO brokers VALUES (1, 'Keystone again')","results":[{"statement":"INSERT INTO brokers VALUES (22, 'New Broker')","columns":[],"dataset":[],"count":0,"truncated":false,"rows_affected":1,"message":"1 row(s) inserted","timings":{"parse_us":5,"execute_us":31}}],"saved":0,"rolled_back":true,"error":"a row with id='1' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it (the transaction was rolled back)"}   (HTTP 400)
{"query":"SELECT count(*) AS brokers FROM brokers WHERE id = 22","results":[{"statement":"SELECT count(*) AS brokers FROM brokers WHERE id = 22","columns":[{"name":"brokers","type":"LONG"}],"dataset":[[0]],"count":1,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":9,"execute_us":63}}],"timings":{"parse_us":9,"execute_us":63}}

Broker 22 is not there: "saved":0,"rolled_back":true. Statements that cannot run in a transaction (CREATE, DROP, ALTER, TRUNCATE, BACKUP DATABASE, ...) are refused before anything runs. For a transaction over several requests, use POST /session (chapter 15).

limit and truncated

curl.exe -s -G "http://127.0.0.1:9900/exec" --data-urlencode "query=SELECT time, close FROM ochl_minutes WHERE symbol = 'CITY'" --data-urlencode "limit=2" --data-urlencode "compat=0"
{"query":"SELECT time, close FROM ochl_minutes WHERE symbol = 'CITY'","results":[{"statement":"SELECT time, close FROM ochl_minutes WHERE symbol = 'CITY'","columns":[{"name":"time","type":"TIMESTAMP"},{"name":"close","type":"DOUBLE"}],"dataset":[["2026-09-09T05:15:00.000Z",512.1],["2026-09-09T05:16:00.000Z",511.9]],"count":2,"truncated":true,"rows_affected":0,"message":"","timings":{"parse_us":46,"execute_us":347}}],"timings":{"parse_us":46,"execute_us":347}}

"truncated":true tells you that more rows exist. If you need all rows, use a bigger limit, /exp, or read page by page with ORDER BY time LIMIT 10000 OFFSET ....

format=columns and ETag

A chart or a program that works column by column can ask for one list per column. It is also shorter, because the column names are not repeated:

# format=columns: one list per column instead of one list per row.
curl.exe -s -G http://127.0.0.1:9900/exec --data-urlencode "query=SELECT symbol, close FROM ochl_day WHERE time IN '2026-09-10'" --data-urlencode "format=columns" --data-urlencode "compat=0"
""
# Every answer with rows has an ETag. curl saves it, then sends it back as If-None-Match:
# while the rows are the same, the answer is 304 and has no body.
$url = "http://127.0.0.1:9900/exec?compat=0&query=SELECT%20count(*)%20AS%20brokers%20FROM%20brokers"
curl.exe -s --etag-save etag.txt $url
""
"saved ETag: $(Get-Content etag.txt)"
curl.exe -s -w "(HTTP %{http_code})" --etag-compare etag.txt $url
{"query":"SELECT symbol, close FROM ochl_day WHERE time IN '2026-09-10'","results":[{"statement":"SELECT symbol, close FROM ochl_day WHERE time IN '2026-09-10'","columns":[{"name":"symbol","type":"SYMBOL"},{"name":"close","type":"DOUBLE"}],"dataset":{"symbol":["CITY","COAST","VOLT"],"close":[504.9,376.1,191.2]},"count":3,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":15,"execute_us":203}}],"timings":{"parse_us":15,"execute_us":203}}
{"query":"SELECT count(*) AS brokers FROM brokers","results":[{"statement":"SELECT count(*) AS brokers FROM brokers","columns":[{"name":"brokers","type":"LONG"}],"dataset":[[6]],"count":1,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":10,"execute_us":196}}],"timings":{"parse_us":10,"execute_us":196}}
saved ETag: "08452937-5r"
(HTTP 304)

/exp: download CSV

curl.exe -s -G http://127.0.0.1:9900/exp --data-urlencode "query=SELECT time, symbol, close FROM ochl_day WHERE time IN '2026-09-10'" -o closes.csv
Get-Content closes.csv
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
curl.exe -s -w "   (HTTP %{http_code})" -G http://127.0.0.1:9900/exp --data-urlencode "query=INSERT INTO brokers VALUES (22, 'Export Test')"
{"error":"CSV export needs a SELECT, SHOW or EXPLAIN statement"}   (HTTP 400)

/imp: load CSV

POST /imp loads a CSV file into a table, the way QuestDB's /imp does. Send the file as a form part named data (or the CSV itself as the body).

curl.exe -s -F "data=@day_closes.csv" "http://127.0.0.1:9900/imp?name=day_closes&timestamp=time&partitionBy=YEAR&fmt=json"
{"status":"OK","location":"day_closes","rowsRejected":0,"rowsImported":2,"header":true,"partitionBy":"YEAR","timestamp":"time","columns":[{"name":"time","type":"TIMESTAMP","size":8,"errors":0},{"name":"symbol","type":"STRING","size":0,"errors":0},{"name":"close","type":"DOUBLE","size":8,"errors":0},{"name":"volume","type":"LONG","size":8,"errors":0}]}
ParameterMeaningDefault
nameThe table. A new name makes a new table.the file name without .csv
timestampNew table: its time column (a TIMESERIES table)none (a relational table)
partitionByNew table: NONE, DAY, MONTH or YEAR (needs timestamp)NONE
overwritetrue: drop the table and make it again from the filefalse
atomicityA value that does not fit its column: skipCol makes it NULL, skipRow leaves the row out, abort loads nothingskipCol
delimiter,, ;, \t or |found from the first line
forceHeadertrue: the first line is a headerfound from the first line
fmtjson for the JSON answer above; otherwise a short text answertext
dbThe databasethe default database
curl.exe -s -w "   (HTTP %{http_code})" -F "data=@bad.csv" "http://127.0.0.1:9900/imp?name=day_closes&atomicity=abort&fmt=json"
{"status":"line 2, column 'close': 'abc' is not a number (atomicity=abort: nothing was imported)"}   (HTTP 400)

POST /write: line protocol

For live feeds and sensors, the line protocol of InfluxDB (also used by QuestDB) is the fastest way to load rows. Each line is one row: table,tag=text field=value,field=value time.

# Two new live prices in the line format: table,tag=text field=value,... time
$lines = "live_data,symbol=CITY ltp=528.5,ltv=120i,volume=45120i 1789031700`nlive_data,symbol=VOLT ltp=192.4,ltv=80i,volume=21000i 1789031700"
curl.exe -s -w "(HTTP %{http_code})" "http://127.0.0.1:9900/write?precision=s" --data-binary $lines
""
curl.exe -s -G http://127.0.0.1:9900/exec --data-urlencode "query=SELECT timestamp, symbol, ltp, ltv, volume FROM live_data WHERE timestamp = '2026-09-10T15:00:00+05:45'" --data-urlencode "compat=0"
(HTTP 204)
{"query":"SELECT timestamp, symbol, ltp, ltv, volume FROM live_data WHERE timestamp = '2026-09-10T15:00:00+05:45'","results":[{"statement":"SELECT timestamp, symbol, ltp, ltv, volume FROM live_data WHERE timestamp = '2026-09-10T15:00:00+05:45'","columns":[{"name":"timestamp","type":"TIMESTAMP"},{"name":"symbol","type":"SYMBOL"},{"name":"ltp","type":"DOUBLE"},{"name":"ltv","type":"LONG"},{"name":"volume","type":"LONG"}],"dataset":[["2026-09-10T09:15:00.000Z","CITY",528.5,120,45120],["2026-09-10T09:15:00.000Z","VOLT",192.4,80,21000]],"count":2,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":12,"execute_us":58}}],"timings":{"parse_us":12,"execute_us":58}}

GET /api/info

Everything the console's sidebar shows.

$info = Invoke-RestMethod http://127.0.0.1:9900/api/info
"version: $($info.version)   log LSN: $($info.last_lsn)   damaged files: $($info.damaged.Count)"
$info.tables | Select-Object name, kind, partition, timestamp_column, rows | Format-Table -AutoSize
version: 0.2.0-dev   log LSN: 32   damaged files: 0

name         kind       partition timestamp_column rows
----         ----       --------- ---------------- ----
brokers      relational NONE                          6
trade_log    timeseries DAY       date               32
live_data    timeseries DAY       timestamp          12
ochl_day     timeseries YEAR      time               57
ochl_minutes timeseries MONTH     time              956
securities   relational NONE                          5

The JSON has these fields (the start of a real answer, cut):

{"dir":"market","version":"0.2.0-dev","opened_ms":1789113512561,"server_time_ms":1789113526709,"last_lsn":1098,"data_files":10,"data_bytes":75739,"tables":[{"name":"brokers","kind":"relational","partition":"NONE","timestamp_column":"","rows":7,"bytes":336,"columns":[{"name":"id","type":"INT","primary_key":true,"not_null":false,"computed":false,"references":""},{"name":"name","type":"STRING","primary_key":false,"not_null":true,"computed":false,"references":""}],"create_sql":"CREATE TABLE brokers (\n  id INT PRIMARY KEY,\n  name STRING NOT NULL\n)"}, ...
FieldMeaning
dir, versionDatabase folder and KDB version
opened_ms, server_time_msWhen the database was opened; the server clock (ms since 1970, UTC)
last_lsnNumber of the last change in the log
data_files, data_bytesCount and size of the data files
tables[]name, kind, partition, timestamp_column, rows, bytes, columns[], create_sql
damaged[]Damaged files (empty when all is OK)
recovery_summaryWhat happened when the database was opened
backup_dir, last_backupBackup folder and the newest backup (or null)
auth_requiredToken mode
read_onlyThe database (or the whole server) takes no changes
protectionThe server's statement_timeout_ms, max_result_bytes, max_groups, max_queries_per_user, safe_updates, running statements, server_read_only, audit
backup_scheduleWith --backup-schedule: schedule, copy_to, next_run_ms, last_run_ms, last_run_ok, last_ok_ms, running, warning, and databases[] of the last run (ok, error, dir, files, bytes, copy_ok, copy_error, ...)

POST /api/check

curl.exe -s -X POST http://127.0.0.1:9900/api/check
{"ok":true,"files_checked":11,"bytes_checked":76566,"wal_segments_checked":1,"problems":[],"elapsed_us":11880}

ok is false when there is damage; problems then lists it. For big databases this reads every file, so it can take time.

POST /api/backup and GET /api/backups

curl.exe -s -X POST http://127.0.0.1:9900/api/backup
{"name":"20260923-120933-857","dir":"backups/20260923-120933-857","created_ms":1790165373857,"created":"2026-09-23T12:09:33.857Z","end_lsn":32,"checkpoint_lsn":32,"files":9,"bytes":194672}
curl.exe -s http://127.0.0.1:9900/api/backups
{"backups":[{"name":"20260923-120933-857","dir":"backups/20260923-120933-857","created_ms":1790165373857,"created":"2026-09-23T12:09:33.857Z","end_lsn":32,"checkpoint_lsn":32,"files":9,"bytes":194672}]}
curl.exe -s "http://127.0.0.1:9900/exec?compat=0" --data-binary "BACKUP DATABASE"
{"query":"BACKUP DATABASE","results":[{"statement":"BACKUP DATABASE","columns":[{"name":"backup","type":"STRING"},{"name":"files","type":"LONG"},{"name":"bytes","type":"LONG"},{"name":"log_lsn","type":"LONG"}],"dataset":[["backups/20260923-120934-350",9,194672,32]],"count":1,"truncated":false,"rows_affected":0,"message":"backup created and verified","timings":{"parse_us":8,"execute_us":51416}}],"timings":{"parse_us":8,"execute_us":51416}}

A daily backup script for a running server (see chapter 7):

# daily-backup-server.ps1
$r = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/api/backup"
"backup $($r.name): $($r.files) files, $($r.bytes) bytes"
$c = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/api/check"
if (-not $c.ok) { Write-Output "CHECK FOUND A PROBLEM: $($c.problems -join '; ')"; exit 2 }
"check ok: $($c.files_checked) files"
backup 20260923-121039-826: 9 files, 195468 bytes
check ok: 14 files

Many databases

kdb serve without a folder serves every database in the data folder (chapter 1). Each request chooses its database:

db= wins when both are given. Without either, the default database is used (--db <name>, else default, which is created when first used). Names ignore case.

This example starts a second server on port 9946 for the data folder kdb-data:

# A server for a whole data folder: every database in it, chosen per request.
# --pg-port 0: the first server already has the Postgres port 8832.
$server = Start-Process kdb -ArgumentList "serve", "--data-dir", "kdb-data", "--port", "9946", "--pg-port", "0" -PassThru -WindowStyle Hidden
Start-Sleep -Seconds 1
$u = "http://127.0.0.1:9946"

# Create two databases.
Invoke-RestMethod -Method Post "$u/api/databases?name=market" | ConvertTo-Json -Compress
Invoke-RestMethod -Method Post "$u/api/databases" -ContentType "application/json" -Body '{"name":"demo"}' | ConvertTo-Json -Compress

# The same table name in both, with different rows: db=<name> or the X-KDB-Database header picks the database.
$sql = "CREATE TABLE prices (symbol SYMBOL PRIMARY KEY, close DOUBLE)"
$null = Invoke-RestMethod -Method Post "$u/exec?db=market" -ContentType "text/plain" -Body "$sql; INSERT INTO prices VALUES ('CITY', 527.1), ('COAST', 401.9)"
$null = Invoke-RestMethod -Method Post "$u/exec" -Headers @{ "X-KDB-Database" = "demo" } -ContentType "text/plain" -Body "$sql; INSERT INTO prices VALUES ('TEST', 1)"
curl.exe -s "$u/exp?db=market&query=SELECT%20*%20FROM%20prices"
curl.exe -s "$u/exp?db=demo&query=SELECT%20*%20FROM%20prices"

# A query in market can read a table of demo: database.table
curl.exe -s -G "$u/exp" --data-urlencode "db=market" --data-urlencode "query=SELECT symbol, close FROM prices UNION ALL SELECT symbol, close FROM demo.prices"

# Every database, with its table count.
(Invoke-RestMethod "$u/api/databases").databases | Select-Object name, tables, default | Format-Table -AutoSize

# A database that does not exist.
curl.exe -s -w "   (HTTP %{http_code})" "$u/exec?db=nope&query=SELECT%201"
""

# Drop one: its folder moves to kdb-data\_trash.
(Invoke-RestMethod -Method Delete "$u/api/databases/demo").dropped
"demo folder: $(Test-Path kdb-data\demo)   folders in _trash: $((Get-ChildItem kdb-data\_trash).Count)"

Stop-Process -Id $server.Id
{"name":"market","created":true}
{"name":"demo","created":true}
symbol,close
CITY,527.1
COAST,401.9
symbol,close
TEST,1
symbol,close
CITY,527.1
COAST,401.9
TEST,1

name   tables default
----   ------ -------
demo        1   False
market      1   False

{"error":"database 'nope' does not exist; create it with CREATE DATABASE nope"}   (HTTP 404)
True
demo folder: False   folders in _trash: 1

A query can also read a table of another database on the same server: write database.table, like demo.prices above. It works in SELECT, JOIN, subqueries and INSERT ... SELECT. A statement only changes tables of its own database. The user needs the right to read the other database. The help topic Queries across databases has more.

GET /api/databases

{"databases":[{"name":"demo","dir":"kdb-data/demo","default":false,"open":true,"tables":1,"bytes":235},{"name":"market","dir":"kdb-data/market","default":false,"open":true,"tables":1,"bytes":235}],"default":"default","data_dir":"kdb-data","can_create":true}
FieldMeaning
databases[]Every database, sorted by name: name, dir (its folder), default, open, tables, bytes (data files). When a database cannot be opened (for example another program uses it), tables is null and error says why.
defaultThe database used when a request has no db=
data_dirThe data folder (empty for kdb serve <folder>)
can_createfalse for kdb serve <folder>: that server has one database and cannot create or drop others

Listing opens every database, so the list can count its tables.

POST /api/databases and DELETE /api/databases/&lt;name&gt;

RequestAnswer
POST /api/databases?name=market (or form name=market, or JSON {"name":"market"})200 {"name":"market","created":true}; 409 if it exists; 400 for a bad name
DELETE /api/databases/demo200 {"name":"demo","dropped":true,"moved_to":"kdb-data/_trash/1789136015339-demo"}; 404 if it does not exist; 409 while a request uses it
GET /api/databases/demo405: changes need POST or DELETE

A dropped database is not deleted. Its folder moves to <data folder>/_trash/<time in ms>-<name>. To get it back, stop the server and move the folder back into the data folder.

Database statements in SQL

/exec also runs CREATE DATABASE, DROP DATABASE, SHOW DATABASES and USE (chapter 11). CREATE and DROP DATABASE need POST. USE name changes the database only for the rest of that request:

curl.exe -s "http://127.0.0.1:9946/exec?compat=0" --data-binary "USE market; SELECT count(*) FROM prices"
{"query":"USE market; SELECT count(*) FROM prices","results":[{"statement":"USE market","columns":[],"dataset":[],"count":0,"truncated":false,"rows_affected":0,"message":"using database 'market'","timings":{"parse_us":0,"execute_us":8}},{"statement":"SELECT count(*) FROM prices","columns":[{"name":"count(*)","type":"LONG"}],"dataset":[[2]],"count":1,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":22,"execute_us":91}}],"timings":{"parse_us":22,"execute_us":99}}

The next request uses db= (or the default database) again.

Cursors: read a big result bit by bit

/exec returns at most 1,000,000 rows at once, and a big result is a big reply. A cursor runs the SELECT once, keeps the rows on the server, and gives them to you a few at a time.

RequestAnswer
POST /cursor with one SELECT as the body{"cursor":"<id>","columns":[...],"rows":N}
GET /cursor/<id>?fetch=Nthe next N rows (default 1,000): {"cursor","columns","dataset","count","done"}. done is true when no rows are left.
DELETE /cursor/<id>{"cursor":"<id>","closed":true}
# A cursor: the server runs the SELECT once and keeps the rows; you read them bit by bit.
$c = Invoke-RestMethod -Method Post "http://127.0.0.1:9900/cursor" -ContentType "text/plain" `
  -Body "SELECT time, symbol, close FROM ochl_minutes WHERE time IN '2026-09-10' ORDER BY time, symbol"
"rows kept: $($c.rows)   columns: $(($c.columns | ForEach-Object { $_.name }) -join ', ')"

# GET only reads the kept rows: 3 at a time.
foreach ($i in 1..2) {
  $page = Invoke-RestMethod "http://127.0.0.1:9900/cursor/$($c.cursor)?fetch=3"
  $page.dataset | ForEach-Object { $_ -join ' | ' }
  "count: $($page.count)   done: $($page.done)"
}

# Close it. A closed (or unknown) cursor answers 404.
(Invoke-RestMethod -Method Delete "http://127.0.0.1:9900/cursor/$($c.cursor)").closed
curl.exe -s -o NUL -w "HTTP %{http_code}" "http://127.0.0.1:9900/cursor/$($c.cursor)?fetch=3"
""
rows kept: 476   columns: time, symbol, close
2026-09-10T05:15:00.000Z | CITY  | 504.4
2026-09-10T05:15:00.000Z | COAST | 380
2026-09-10T05:16:00.000Z | CITY  | 504.6
count: 3   done: False
2026-09-10T05:16:00.000Z | COAST | 379.6
2026-09-10T05:17:00.000Z | CITY  | 504.2
2026-09-10T05:17:00.000Z | COAST | 380.1
count: 3   done: False
True
HTTP 404

Clients

Every client below does the same three things: POST the SQL to /exec?compat=0, read results, and turn HTTP errors into error messages. The files are in examples/08-http-api/.

PowerShell

$sql = "SELECT symbol, sum(quantity) AS shares FROM trade_log WHERE date IN '2026-09-10' GROUP BY symbol ORDER BY shares DESC"
$r = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/exec?compat=0" -Body $sql -ContentType "text/plain; charset=utf-8"

$result = $r.results[0]
$names = $result.columns | ForEach-Object { $_.name }
"columns: $($names -join ', ')"
foreach ($row in $result.dataset) { "  $($row -join ' | ')" }
columns: symbol, shares
  COAST | 2850
  CITY  | 910
  TELCO | 770
  VOLT  | 400

To send a .sql file with non-English text, send the bytes of the file, so no encoding is lost:

# Send a whole .sql file in one request.
@"
INSERT OR REPLACE INTO ochl_day VALUES ('2026-09-14', 'COAST', 378.5, 381.0, 377.2, 380.4, 99000);
INSERT OR REPLACE INTO ochl_day VALUES ('2026-09-14', 'VOLT', 193.7, 196.0, 192.8, 195.1, 70500);
"@ | Set-Content load.sql -Encoding UTF8

$body = [System.IO.File]::ReadAllBytes((Resolve-Path load.sql))
$r = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:9900/exec?compat=0" -Body $body -ContentType "text/plain; charset=utf-8"
$r.results | ForEach-Object { $_.message }
1 row(s) inserted
1 row(s) inserted

Python (standard library only)

client.py:

# client.py - query KDB from Python. Only the standard library is used.
import json
import os
import urllib.error
import urllib.parse
import urllib.request

KDB = os.environ.get("KDB_URL", "http://127.0.0.1:9900")
TOKEN = ""  # set this if the server was started with --token

def kdb(sql, limit=10000):
    url = KDB + "/exec?" + urllib.parse.urlencode({"limit": limit, "compat": 0})
    req = urllib.request.Request(url, data=sql.encode("utf-8"), method="POST")
    req.add_header("Content-Type", "text/plain; charset=utf-8")
    if TOKEN:
        req.add_header("Authorization", "Bearer " + TOKEN)
    try:
        with urllib.request.urlopen(req) as resp:
            return json.load(resp)["results"]
    except urllib.error.HTTPError as e:
        body = json.load(e)
        raise RuntimeError(f"KDB error (HTTP {e.code}): {body['error']}") from None

# Insert a row (INSERT OR REPLACE: safe to run again).
print(kdb("INSERT OR REPLACE INTO ochl_day VALUES "
          "('2026-09-13', 'CITY', 505, 509, 503.1, 508.2, 91000)")[0]["message"])

# Read rows as dictionaries.
res = kdb("SELECT time, symbol, close FROM ochl_day WHERE symbol = 'CITY' AND time >= '2026-09-09'")[0]
names = [c["name"] for c in res["columns"]]
for row in res["dataset"]:
    print(dict(zip(names, row)))

# Errors become exceptions.
try:
    kdb("SELECT clsoe FROM ochl_day")
except RuntimeError as err:
    print(err)
python client.py
1 row(s) inserted
{'time': '2026-09-09T00:00:00.000Z', 'symbol': 'CITY', 'close': 504.6}
{'time': '2026-09-10T00:00:00.000Z', 'symbol': 'CITY', 'close': 504.9}
{'time': '2026-09-13T00:00:00.000Z', 'symbol': 'CITY', 'close': 508.2}
KDB error (HTTP 400): line 1, column 8: unknown column 'clsoe' (table 'ochl_day' has: time symbol open high low close volume)

Tested with Python 3.15. To turn a time text into a Python datetime: datetime.fromisoformat(t.replace("Z", "+00:00")).

Node.js (18 or newer)

client.mjs:

// client.mjs - query KDB from Node.js 18 or newer (fetch is built in).
const KDB = process.env.KDB_URL || "http://127.0.0.1:9900";
const TOKEN = ""; // set this if the server was started with --token

async function kdb(sql, limit = 10000) {
  const res = await fetch(`${KDB}/exec?limit=${limit}&compat=0`, {
    method: "POST",
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
    },
    body: sql,
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`KDB error (HTTP ${res.status}): ${body.error}`);
  return body.results;
}

const [res] = await kdb(
  "SELECT symbol, ltp, pct_change FROM live_data LATEST ON timestamp PARTITION BY symbol ORDER BY pct_change DESC"
);
const names = res.columns.map((c) => c.name);
for (const row of res.dataset) {
  console.log(Object.fromEntries(names.map((n, i) => [n, row[i]])));
}

try {
  await kdb("INSERT INTO brokers VALUES (58, 'Duplicate')");
} catch (err) {
  console.log(err.message);
}
node client.mjs
{ symbol: 'CITY', ltp: 527.1, pct_change: 2.71 }
{ symbol: 'TELCO', ltp: 897.7, pct_change: 1.9 }
{ symbol: 'COAST', ltp: 401.9, pct_change: 0.7 }
{ symbol: 'VOLT', ltp: 184, pct_change: -0.86 }
KDB error (HTTP 400): a row with id='58' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it

Tested with Node 24.

Go (standard library only)

client.go:

// client.go - query KDB from Go (standard library only).
// Run: go run client.go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
)

var kdbURL = "http://127.0.0.1:9900"

const token = "" // set this if the server was started with --token

type column struct {
	Name string `json:"name"`
	Type string `json:"type"`
}

type result struct {
	Statement    string          `json:"statement"`
	Columns      []column        `json:"columns"`
	Dataset      [][]interface{} `json:"dataset"`
	Count        int             `json:"count"`
	RowsAffected int             `json:"rows_affected"`
	Message      string          `json:"message"`
}

type response struct {
	Results []result `json:"results"`
	Error   string   `json:"error"`
	Line    int      `json:"line"`
	Column  int      `json:"column"`
}

func query(sql string) ([]result, error) {
	u := kdbURL + "/exec?" + url.Values{"compat": {"0"}, "limit": {"10000"}}.Encode()
	req, err := http.NewRequest("POST", u, strings.NewReader(sql))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "text/plain; charset=utf-8")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	var r response
	if err := json.Unmarshal(body, &r); err != nil {
		return nil, err
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("KDB error (HTTP %d): %s", resp.StatusCode, r.Error)
	}
	return r.Results, nil
}

func main() {
	if u := os.Getenv("KDB_URL"); u != "" {
		kdbURL = u
	}
	res, err := query("SELECT time, first(open) AS open, last(close) AS close FROM ochl_minutes " +
		"WHERE symbol = 'CITY' AND time IN '2026-09-10' SAMPLE BY 1h TIME ZONE 'Asia/Kathmandu'")
	if err != nil {
		panic(err)
	}
	for _, row := range res[0].Dataset {
		// Times are strings like "2026-09-10T05:15:00.000Z"; numbers are float64.
		fmt.Printf("%v open=%v close=%v\n", row[0], row[1], row[2])
	}

	if _, err := query("DELETE FROM brokers WHERE id = 58"); err != nil {
		fmt.Println(err)
	}
}
go run client.go
2026-09-10T05:15:00.000Z open=504.6 close=505.3
2026-09-10T06:15:00.000Z open=505.3 close=502.7
2026-09-10T07:15:00.000Z open=502.7 close=502.6
2026-09-10T08:15:00.000Z open=502.6 close=504.9
KDB error (HTTP 400): cannot remove brokers(id) = '58': table 'trade_log' column 'buyer' still refers to it (foreign key 'trade_log_buyer_fkey')

Tested with Go 1.26. Parse times with time.Parse(time.RFC3339Nano, s). A Go app can also use its PostgreSQL driver pgx on port 8832 (chapter 14). This HTTP client needs no driver at all.

C# (.NET 6 or newer)

Client.cs (not run while writing this tutorial: there was no .NET SDK on the test PC):

// Client.cs - query KDB from C# (.NET 6 or newer).
// NOT RUN while writing the tutorial (no .NET SDK on that computer).
// Use it in a console project: dotnet new console, then replace Program.cs.
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

class KdbClient
{
    static readonly HttpClient Http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:9900") };
    const string Token = ""; // set this if the server was started with --token

    static async Task<JsonElement> Query(string sql)
    {
        using var request = new HttpRequestMessage(HttpMethod.Post, "/exec?compat=0&limit=10000");
        request.Content = new StringContent(sql, Encoding.UTF8, "text/plain");
        if (Token != "") request.Headers.Add("Authorization", "Bearer " + Token);
        using var response = await Http.SendAsync(request);
        var text = await response.Content.ReadAsStringAsync();
        var json = JsonDocument.Parse(text).RootElement.Clone();
        if (!response.IsSuccessStatusCode)
            throw new Exception($"KDB error (HTTP {(int)response.StatusCode}): {json.GetProperty("error").GetString()}");
        return json.GetProperty("results");
    }

    static async Task Main()
    {
        var results = await Query("SELECT symbol, close FROM ochl_day WHERE time IN '2026-09-10'");
        foreach (var row in results[0].GetProperty("dataset").EnumerateArray())
            Console.WriteLine($"{row[0].GetString()} {row[1].GetDouble()}");
    }
}

Access token

A second server on another port, with a token. Every kdb serve also opens the PostgreSQL port 8832 (chapter 14), and only one server can have it, so a second server needs --pg-port 0 (off) or another port:

kdb serve tokendb --port 9944 --pg-port 0 --token change-me-please

Then:

curl.exe -s http://127.0.0.1:9944/health
""
curl.exe -s -w "   (HTTP %{http_code})" "http://127.0.0.1:9944/exec?query=SELECT%201"
""
curl.exe -s -w "   (HTTP %{http_code})" -H "Authorization: Bearer wrong" "http://127.0.0.1:9944/exec?query=SELECT%201"
""
curl.exe -s -H "Authorization: Bearer change-me-please" "http://127.0.0.1:9944/exec?query=SELECT%201%20AS%20one&compat=0"
""
curl.exe -s --cookie "kdb_token=change-me-please" "http://127.0.0.1:9944/exp?query=SELECT%201%20AS%20one"
{"status":"ok","version":"0.2.0-dev","server_time_ms":1789114675621,"auth_required":true,"login":false}
{"error":"access token required"}   (HTTP 401)
{"error":"wrong access token"}   (HTTP 401)
{"query":"SELECT 1 AS one","results":[{"statement":"SELECT 1 AS one","columns":[{"name":"one","type":"LONG"}],"dataset":[[1]],"count":1,"truncated":false,"rows_affected":0,"message":"","timings":{"parse_us":26,"execute_us":16}}],"timings":{"parse_us":26,"execute_us":16}}
one
1

Stop a slow request

kdb serve stops every statement after --statement-timeout (60 s by default) with HTTP 408. Send your own shorter time, or give the request an id so that you can stop it:

HeaderDoes
X-KDB-Statement-Timeout: 5sa shorter time for this request (admins may also make it longer)
X-KDB-Request-Id: report-17DELETE /exec/report-17 stops it (your own requests; admins: all)
X-KDB-Safe-Updates: onrefuse UPDATE and DELETE without WHERE (off when the server has --safe-updates)
{"query":"SELECT ...","results":[],"error":"canceling statement due to user request"}   (HTTP 408)

Closing the connection also stops the statement. Nothing of a stopped change is saved. Chapter 21 has the details.

Users and logins

When the data folder has users (CREATE USER, or kdb user add), every request needs a login. Chapter 16 explains users and roles; here is only the HTTP part.

HowSendFor
HTTP Basiccurl -u name:password ... (header Authorization: Basic ...)scripts; KDB remembers a good password for 10 minutes, so it is not slow
Log in oncePOST /api/login with {"user":"...","password":"..."} (JSON or form; users with two-factor login add "code":"123456", chapter 21). The answer has token and sets the cookie kdb_login (HttpOnly, SameSite=Strict, 12 hours). Then send Authorization: Bearer <token>.apps, the web console
Log outPOST /api/logout with the cookie or the Bearer token
The --tokenAuthorization: Bearer <token>everything, as before
AnswerMeans
401 {"error":"login required: ...","login":true}no login, or a wrong password. The header is WWW-Authenticate: Basic realm="kdb" (the console gets Bearer, so the browser shows no password box).
403 {"error":"INSERT needs role write on database 'shop'; user 'report' has read"}logged in, but the role is too weak
429 {"error":"too many failed logins; try again in 4 s"} and Retry-After5 failed logins for one user from one address: wait 1 s, 2 s, 4 s, ... up to 15 minutes

Rights per route: read for /exec and /exp with reading SQL, /api/info, /api/backups and cursors; write for changing SQL and /write; admin on the database for schema changes, /api/check and /api/backup; ROLE admin for POST /api/databases, DELETE /api/databases/<name> and user statements. GET /api/databases lists only the databases the user may read. A transaction session (POST /session) can only be used by the user who opened it.

Safety rules of the API

The server refuses requests that a web page in your browser could send behind your back:

curl.exe -s -w "   (HTTP %{http_code})" -H "Sec-Fetch-Site: cross-site" "http://127.0.0.1:9900/exec?query=SELECT%201"
""
curl.exe -s -w "   (HTTP %{http_code})" -H "Origin: http://example.com" "http://127.0.0.1:9900/exec?query=SELECT%201"
""
curl.exe -s -w "   (HTTP %{http_code})" -H "Host: market.example.com" "http://127.0.0.1:9900/exec?query=SELECT%201"
{"error":"cross-site requests are not allowed"}   (HTTP 403)
{"error":"cross-origin requests are not allowed"}   (HTTP 403)
{"error":"host name 'market.example.com' is not allowed without an access token; use localhost or start the server with --token"}   (HTTP 403)
RuleWhy
GET /exec and /exp only readLinks and images can send GET requests
Browser requests from other sites get 403A bad web page must not use your local KDB
Without a token, the Host must be localhost or 127.x.x.xStops DNS rebinding. A reverse proxy needs --token or users.
kdb serve --bind 0.0.0.0 without --token and without users does not startNobody can open all data to the network by mistake
Login cookies are HttpOnly and SameSite=Strict; POST /api/login from another site gets 403Scripts and other sites cannot steal or use a login
Failed logins are logged and slowed downGuessing passwords does not work
The token is compared in constant timeGuessing it by timing does not work

Programs like curl, Python, Go and Node do not send Sec-Fetch-Site or Origin, so they are not affected. JavaScript in a web page on another site cannot call KDB (there are no CORS headers). Call KDB from your backend (for example the 006 Go app), not from browser code.

Limits

LimitValue
Rows per statement (limit)default 10,000 for /exec, 1,000,000 for /exp; at most 1,000,000
Request body64 MB
Request headers64 KB
Time to send a request30 seconds
Idle keep-alive connectionclosed after 30 seconds
Worker threads8: at most 8 requests run at once, the next ones wait for a free worker
Open cursors (/cursor)16 per database; unused ones close after 10 minutes
Open sessions (/session)1,000; unused ones close after 10 minutes
Writers at the same timeone per table; writers of different tables run together
Readers at the same timemany; a read never waits for another read
Time of one statement--statement-timeout (60 s)
Statements of one user at the same time--max-queries-per-user (32)

Reads see the last committed rows. They do not wait for an open transaction, only for a statement that is running right now (usually a millisecond; a long CALL or trigger can take longer, see --reader-yield in chapter 15). A writer waits only for another writer of the same table: at most 5 seconds, then HTTP 503 table 'x' is busy (chapter 15).

Not available yet

FeatureQuestDB has itKDB
ILP over its own TCP portyes (port 9009)no. Send the same lines over HTTP: POST /write.
HTTPSno (use a proxy)no (use a proxy)

The PostgreSQL protocol is there: port 8832, for psql, DBeaver, Go pgx and Python psycopg (chapter 14).

Try it yourself

  1. With curl, get the newest live price of TELCO as JSON, without the QuestDB copy.
  2. Download all COAST daily candles as coast.csv.
  3. In Python, insert a stock index snapshot row and read it back. (Create the table first with POST.)
Answers
  1. curl.exe -s -G http://127.0.0.1:9900/exec --data-urlencode "query=SELECT symbol, ltp, timestamp FROM live_data WHERE symbol = 'TELCO' LATEST ON timestamp PARTITION BY symbol" --data-urlencode "compat=0"
  2. curl.exe -s -G http://127.0.0.1:9900/exp --data-urlencode "query=SELECT * FROM ochl_day WHERE symbol = 'COAST' ORDER BY time" -o coast.csv
  3. With kdb() from client.py:
    kdb("CREATE TABLE IF NOT EXISTS index_live (time TIMESTAMP, name SYMBOL, value DOUBLE) "
        "TIMESERIES(time) PARTITION BY DAY DEDUP KEY (name, time)")
    kdb("INSERT OR REPLACE INTO index_live VALUES ('2026-09-10T15:00:00+05:45', 'MARKET', 2650.5)")
    print(kdb("SELECT * FROM index_live")[0]["dataset"])

    It prints [['2026-09-10T09:15:00.000Z', 'MARKET', 2650.5]].


Next: 9. Backup and safety