# Cursors > Run a query once, keep its rows, and read them a few at a time. - Category: Cursors - Keywords: cursor, CURSOR, DECLARE CURSOR, NEXT, ALL, paging, page by page, read in parts, big result, snapshot, FETCH NEXT, FETCH ALL, too many open cursors, is not open - See also: [Sessions and cursors over HTTP](http-sessions-and-cursors.md), [The procedure language (local-cursors)](procedure-language.md#local-cursors), [Transactions](transactions.md), [ORDER BY, LIMIT, OFFSET](order-by-limit.md), [13. Functions, procedures and triggers](../tutorial/13-functions-procedures-triggers.md) A **cursor** runs a `SELECT` once and keeps its rows. Then you read the rows a part at a time with `FETCH`, and end with `CLOSE`. Use it when a result is too big to read at once, for example to send a long trade log to an app page by page. - The rows are a **snapshot** (a copy made at `DECLARE`): changes to the table after that do not change what `FETCH` returns. - A cursor lives until `CLOSE`, or until the end of the text or session (see [where a cursor lives](cursors.md#lifetime)). - Over HTTP, `POST /cursor` does the same job (see [cursors over HTTP](cursors.md#http)). - Inside a procedure, a cursor reads one row at a time into variables: see [cursors in a body](procedure-language.md#local-cursors). **Example: The trade log of one day, 5 trades at a time** (sample data: sample) ```sql DECLARE trades CURSOR FOR SELECT transaction_id, symbol, buyer, seller, quantity, rate FROM trade_log WHERE date = '2026-09-10' ORDER BY transaction_id; FETCH NEXT 5 FROM trades; FETCH NEXT 5 FROM trades; CLOSE trades; ``` Result: `DECLARE trades CURSOR FOR SELECT transaction_id, symbol, buyer, seller, quantity, rate FROM trade_lo` ```text cursor 'trades' declared: 24 row(s) ``` `FETCH NEXT 5 FROM trades` | transaction_id (LONG) | symbol (SYMBOL) | buyer (INT) | seller (INT) | quantity (LONG) | rate (DOUBLE) | |---|---|---|---|---|---| | 100009 | COAST | 34 | 45 | 50 | 400 | | 100010 | CITY | 1 | 45 | 50 | 517 | | 100011 | VOLT | 1 | 58 | 50 | 184 | | 100012 | VOLT | 58 | 45 | 200 | 184 | | 100013 | COAST | 58 | 1 | 50 | 398 | (5 rows) ```text 5 row(s) fetched, 19 left ``` `FETCH NEXT 5 FROM trades` | transaction_id (LONG) | symbol (SYMBOL) | buyer (INT) | seller (INT) | quantity (LONG) | rate (DOUBLE) | |---|---|---|---|---|---| | 100014 | COAST | 45 | 49 | 20 | 399.5 | | 100015 | COAST | 49 | 45 | 1000 | 398 | | 100016 | VOLT | 34 | 1 | 50 | 185 | | 100017 | TELCO | 49 | 34 | 500 | 881 | | 100018 | CITY | 45 | 1 | 500 | 516 | (5 rows) ```text 5 row(s) fetched, 14 left ``` `CLOSE trades` ```text cursor 'trades' closed ``` ## DECLARE CURSOR Syntax: ```sql DECLARE name CURSOR FOR SELECT ... ``` Runs the query at once and keeps all its rows under a name. | Part | Meaning | |---|---| | `name` | the cursor name. Upper and lower case are the same. | | `SELECT ...` | one query. Not `SHOW`, not `EXPLAIN`. Use `ORDER BY` when the order of the parts matters. | - **Returns:** `cursor 'name' declared: N row(s)`. - **Notes:** - The query runs **once**, now. Its rows stay in memory until the cursor is closed. - A name that is already open in the same text or session is an error: `CLOSE` it first. - `DECLARE name TYPE` (a variable) works only inside a procedure or trigger body. - At most 16 open cursors per database, and at most 10 million rows per cursor (see [limits](cursors.md#limits)). - `DECLARE ... CURSOR` works inside `BEGIN ... COMMIT`, and then it sees the changes of the transaction. **Example: A snapshot: rows deleted after DECLARE are still fetched** (sample data: sample) ```sql DECLARE s CURSOR FOR SELECT symbol, sector FROM securities ORDER BY symbol; DELETE FROM securities WHERE sector = 'Banking'; FETCH ALL FROM s; SELECT count(*) AS securities_now FROM securities; ``` Result: `DECLARE s CURSOR FOR SELECT symbol, sector FROM securities ORDER BY symbol` ```text cursor 's' declared: 5 row(s) ``` `DELETE FROM securities WHERE sector = 'Banking'` ```text 2 row(s) deleted ``` `FETCH ALL FROM s` | symbol (SYMBOL) | sector (STRING) | |---|---| | ACME | Investment | | CITY | Banking | | COAST | Banking | | TELCO | Telecom | | VOLT | Energy | (5 rows) ```text 5 row(s) fetched, no more rows ``` `SELECT count(*) AS securities_now FROM securities` | securities_now (LONG) | |---| | 3 | (1 row) **Example: The same name twice** (shows an error) ```sql DECLARE c CURSOR FOR SELECT 1 AS x; DECLARE c CURSOR FOR SELECT 2 AS x; ``` Result: ```text cursor 'c' declared: 1 row(s) ``` Error: `cursor 'c' is already open; CLOSE it first` **Example: Only a SELECT** (shows an error) ```sql DECLARE c CURSOR FOR SHOW TABLES; ``` Result: Error: `line 1, column 22: expected a query (SELECT ...) after CURSOR FOR but found 'SHOW'` ## FETCH Syntax: ```sql FETCH name FETCH [FROM] name FETCH NEXT [FROM] name FETCH NEXT count FROM name FETCH count FROM name FETCH ALL FROM name ``` Returns the next rows of the cursor and moves past them. | Form | Rows | |---|---| | `FETCH c`, `FETCH FROM c`, `FETCH NEXT FROM c` | the next 1 row | | `FETCH NEXT 100 FROM c`, `FETCH 100 FROM c` | the next 100 rows (fewer at the end) | | `FETCH ALL FROM c` | all rows that are left | - **Returns:** the rows (with the columns of the query), and the message `N row(s) fetched, M left` or `N row(s) fetched, no more rows`. - **Notes:** - With a count or `ALL`, the word `FROM` is needed: `FETCH NEXT 5 c` is an error. - The count must be 1 or more. - After the last row, `FETCH` returns 0 rows (not an error). The cursor stays open until `CLOSE`. - There is no going back (`PRIOR`, `ABSOLUTE`): a cursor only moves forward. - `FETCH ... INTO variables` works only inside a procedure or trigger body. **Example: All forms of FETCH** (sample data: sample) ```sql DECLARE s CURSOR FOR SELECT symbol, sector FROM securities ORDER BY symbol; FETCH s; FETCH NEXT FROM s; FETCH 2 FROM s; FETCH ALL FROM s; FETCH ALL FROM s; ``` Result: `DECLARE s CURSOR FOR SELECT symbol, sector FROM securities ORDER BY symbol` ```text cursor 's' declared: 5 row(s) ``` `FETCH NEXT 1 FROM s` | symbol (SYMBOL) | sector (STRING) | |---|---| | ACME | Investment | (1 row) ```text 1 row(s) fetched, 4 left ``` `FETCH NEXT 1 FROM s` | symbol (SYMBOL) | sector (STRING) | |---|---| | CITY | Banking | (1 row) ```text 1 row(s) fetched, 3 left ``` `FETCH NEXT 2 FROM s` | symbol (SYMBOL) | sector (STRING) | |---|---| | COAST | Banking | | TELCO | Telecom | (2 rows) ```text 2 row(s) fetched, 1 left ``` `FETCH ALL FROM s` | symbol (SYMBOL) | sector (STRING) | |---|---| | VOLT | Energy | (1 row) ```text 1 row(s) fetched, no more rows ``` `FETCH ALL FROM s` | symbol (SYMBOL) | sector (STRING) | |---|---| (0 rows) ```text 0 row(s) fetched, no more rows ``` **Example: Minute candles of CITY, one hour at a time** (sample data: sample) ```sql DECLARE m CURSOR FOR SELECT time, open, high, low, close, volume FROM ochl_minutes WHERE symbol = 'CITY' AND time IN '2026-09-10' ORDER BY time; FETCH NEXT 60 FROM m; FETCH NEXT 60 FROM m; CLOSE m; ``` Result: `DECLARE m CURSOR FOR SELECT time, open, high, low, close, volume FROM ochl_minutes WHERE symbol = 'C` ```text cursor 'm' declared: 240 row(s) ``` `FETCH NEXT 60 FROM m` | time (TIMESTAMP) | open (DOUBLE) | high (DOUBLE) | low (DOUBLE) | close (DOUBLE) | volume (DOUBLE) | |---|---|---|---|---|---| | 2026-09-10T05:15:00.000Z | 504.6 | 504.6 | 504.4 | 504.4 | 960 | | 2026-09-10T05:16:00.000Z | 504.4 | 504.7 | 504.3 | 504.6 | 700 | | 2026-09-10T05:17:00.000Z | 504.6 | 504.9 | 504 | 504.2 | 150 | | 2026-09-10T05:18:00.000Z | 504.2 | 504.6 | 504.1 | 504.4 | 1050 | | 2026-09-10T05:19:00.000Z | 504.4 | 504.8 | 504.1 | 504.5 | 450 | (first 5 of 60 rows) ```text 60 row(s) fetched, 180 left ``` `FETCH NEXT 60 FROM m` | time (TIMESTAMP) | open (DOUBLE) | high (DOUBLE) | low (DOUBLE) | close (DOUBLE) | volume (DOUBLE) | |---|---|---|---|---|---| | 2026-09-10T06:15:00.000Z | 505.3 | 505.4 | 505.1 | 505.4 | 1210 | | 2026-09-10T06:16:00.000Z | 505.4 | 505.7 | 505.2 | 505.6 | 1080 | | 2026-09-10T06:17:00.000Z | 505.6 | 505.9 | 505.6 | 505.8 | 290 | | 2026-09-10T06:18:00.000Z | 505.8 | 506 | 505.6 | 505.6 | 330 | | 2026-09-10T06:19:00.000Z | 505.6 | 505.7 | 505.2 | 505.4 | 270 | (first 5 of 60 rows) ```text 60 row(s) fetched, 120 left ``` `CLOSE m` ```text cursor 'm' closed ``` **Example: FROM is needed with a count** (shows an error) ```sql DECLARE c CURSOR FOR SELECT 1 AS x; FETCH NEXT 5 c; ``` Result: Error: `line 2, column 14: expected FROM and the cursor name, as in FETCH NEXT 100 FROM c, but found 'c'` **Example: A count of 0** (shows an error) ```sql DECLARE c CURSOR FOR SELECT 1 AS x; FETCH 0 FROM c; ``` Result: Error: `line 2, column 7: expected a row count of 1 or more but found '0'` ## CLOSE Syntax: ```sql CLOSE name ``` Closes the cursor and frees the memory of its rows. - **Returns:** `cursor 'name' closed`. - **Notes:** closing a cursor that is not open is an error. Cursors also close by themselves at the end of the text, the end of the session, and after 10 minutes without `FETCH`. Still, close them when you are done: only 16 can be open. **Example** (sample data: sample) ```sql DECLARE b CURSOR FOR SELECT id, name FROM brokers ORDER BY id; FETCH NEXT 2 FROM b; CLOSE b; ``` Result: `DECLARE b CURSOR FOR SELECT id, name FROM brokers ORDER BY id` ```text cursor 'b' declared: 5 row(s) ``` `FETCH NEXT 2 FROM b` | id (INT) | name (STRING) | |---|---| | 1 | Keystone Securities | | 34 | Vision Securities | (2 rows) ```text 2 row(s) fetched, 3 left ``` `CLOSE b` ```text cursor 'b' closed ``` **Example: A cursor that is not open** (shows an error) ```sql CLOSE b; ``` Result: Error: `cursor 'b' is not open` ## Where a cursor lives Syntax: ```sql kdb sql db "DECLARE ...; FETCH ...; CLOSE ..." -- one text kdb shell db -- one session: many lines POST /session, then X-KDB-Session on POST /exec -- one HTTP session: many requests ``` A cursor belongs to the text or the session that declared it. | Where | The cursor lives until | |---|---| | one text: `kdb sql`, `POST /exec` without a session, the web console | `CLOSE`, or the end of that text | | `kdb shell` | `CLOSE`, or the end of the shell | | an HTTP session ([`POST /session`](http-sessions-and-cursors.md)) | `CLOSE`, or the end of the session | | a procedure body (`OPEN`) | the end of the procedure | | an HTTP cursor (`POST /cursor`) | `DELETE /cursor/` | All of them also close after **10 minutes** without `FETCH`. Another session cannot see your cursor, even with the same name. - **Notes:** - When a statement of one text fails, the cursors of that text are closed too. In `kdb shell` and in an HTTP session, cursors stay open after an error. - `ROLLBACK` does not close cursors: their rows were already read at `DECLARE`. **Command line: Two kdb sql commands: the cursor of the first is gone** (sample data: sample; shows an error) ```bash kdb sql market "DECLARE b CURSOR FOR SELECT id, name FROM brokers ORDER BY id; FETCH NEXT 2 FROM b" kdb sql market "FETCH NEXT 2 FROM b" ``` Output: `> kdb sql market "DECLARE b CURSOR FOR SELECT id, name FROM brokers ORDER BY id; FETCH NEXT 2 FROM b"` ```text cursor 'b' declared: 5 row(s) id | name ---+-------------------- 1 | Keystone Securities 34 | Vision Securities (2 rows, 0.0 ms) 2 row(s) fetched, 3 left ``` `> kdb sql market "FETCH NEXT 2 FROM b"` ```text error: cursor 'b' is not open (DECLARE b CURSOR FOR SELECT ... first; an unused cursor closes after 10 minutes) ``` exit code 1 **Command line: kdb shell: the cursor stays from one line to the next** (sample data: sample) ```bash kdb shell market > DECLARE b CURSOR FOR SELECT id, name FROM brokers ORDER BY id; > FETCH NEXT 2 FROM b; > FETCH NEXT 2 FROM b; > FETCH ALL FROM b; > CLOSE b; > .exit ``` Output: ```text KDB 0.2.0-dev shell on market End statements with ';'. Type .help for commands, .exit to quit. kdb> cursor 'b' declared: 5 row(s) kdb> id | name ---+-------------------- 1 | Keystone Securities 34 | Vision Securities (2 rows, 0.0 ms) 2 row(s) fetched, 3 left kdb> id | name ---+-------------------- 45 | Imperial Securities 49 | Online Securities (2 rows, 0.0 ms) 2 row(s) fetched, 1 left kdb> id | name ---+--------------------- 58 | Northgate Securities (1 row, 0.0 ms) 1 row(s) fetched, no more rows kdb> cursor 'b' closed kdb> ``` **HTTP: An HTTP session: DECLARE in one request, FETCH in the next** (sample data: sample) ```http POST /session ### POST /exec X-KDB-Session: {{session}} DECLARE b CURSOR FOR SELECT id, name FROM brokers ORDER BY id ### POST /exec X-KDB-Session: {{session}} FETCH NEXT 3 FROM b ### DELETE /session/{{session}} ``` Response: `POST /session` HTTP 200 (application/json) ```text {"session": "bc2b748660dfa76d2a145ccd793a280b", "idle_timeout_ms": 600000} ``` `POST /exec` HTTP 200 (application/json) ```text { "query": "DECLARE b CURSOR FOR SELECT id, name FROM brokers ORDER BY id", "results": [ { "statement": "DECLARE b CURSOR FOR SELECT id, name FROM brokers ORDER BY id", "columns": [], "dataset": [], "count": 0, "truncated": false, "rows_affected": 0, "message": "cursor 'b' declared: 5 row(s)", "timings": {"parse_us": 48, "execute_us": 295} } ], "columns": [], "dataset": [], "count": 0, "timings": {"parse_us": 48, "execute_us": 295} } ``` `POST /exec` HTTP 200 (application/json) ```text { "query": "FETCH NEXT 3 FROM b", "results": [ { "statement": "FETCH NEXT 3 FROM b", "columns": [ {"name": "id", "type": "INT"}, {"name": "name", "type": "STRING"} ], "dataset": [ [1, "Keystone Securities"], [34, "Vision Securities"], [45, "Imperial Securities"] ], "count": 3, "truncated": false, "rows_affected": 0, "message": "3 row(s) fetched, 2 left", "timings": {"parse_us": 20, "execute_us": 8} } ], "columns": [ {"name": "id", "type": "INT"}, {"name": "name", "type": "STRING"} ], "dataset": [ [1, "Keystone Securities"], [34, "Vision Securities"], [45, "Imperial Securities"] ], "count": 3, "timings": {"parse_us": 20, "execute_us": 8} } ``` `DELETE /session/{{session}}` HTTP 200 (application/json) ```text {"session": "bc2b748660dfa76d2a145ccd793a280b", "closed": true} ``` ## Cursors over HTTP: POST /cursor Syntax: ```sql POST /cursor body: one SELECT -> {"cursor": "", "columns": [...], "rows": N} GET /cursor/?fetch=N -> {"cursor", "columns", "dataset", "count", "done"} DELETE /cursor/ -> {"cursor": "", "closed": true} ``` An app does not need SQL cursor statements over HTTP. `POST /cursor` runs one `SELECT` and keeps the rows on the server; `GET /cursor/?fetch=N` returns the next N rows; `DELETE` closes it. The full description is in [HTTP sessions and cursors](http-sessions-and-cursors.md). - **Notes:** - `fetch` is 1,000 when you do not give it. `done` is `true` when no rows are left. - The id is long and random, so another client cannot guess it. No session is needed. - Use the same database (`db=` or the header `X-KDB-Database`) for all requests of one cursor. - HTTP cursors count toward the same 16 open cursors of the database. - A closed or unknown cursor answers HTTP 404. **HTTP: Page through the trade log over HTTP** (sample data: sample) ```http POST /cursor SELECT transaction_id, symbol, quantity, rate FROM trade_log WHERE date = '2026-09-09' ORDER BY transaction_id ### GET /cursor/{{cursor}}?fetch=3 ### GET /cursor/{{cursor}}?fetch=3 ### DELETE /cursor/{{cursor}} ### GET /cursor/{{cursor}}?fetch=3 ``` Response: `POST /cursor` HTTP 200 (application/json) ```text { "cursor": "d8c238e3eace8c6c", "columns": [ {"name": "transaction_id", "type": "LONG"}, {"name": "symbol", "type": "SYMBOL"}, {"name": "quantity", "type": "LONG"}, {"name": "rate", "type": "DOUBLE"} ], "rows": 8 } ``` `GET /cursor/{{cursor}}?fetch=3` HTTP 200 (application/json) ```text { "cursor": "d8c238e3eace8c6c", "columns": [ {"name": "transaction_id", "type": "LONG"}, {"name": "symbol", "type": "SYMBOL"}, {"name": "quantity", "type": "LONG"}, {"name": "rate", "type": "DOUBLE"} ], "dataset": [ [100001, "TELCO", 1000, 881], [100002, "CITY", 10, 515], [100003, "CITY", 500, 513] ], "count": 3, "done": false } ``` `GET /cursor/{{cursor}}?fetch=3` HTTP 200 (application/json) ```text { "cursor": "d8c238e3eace8c6c", "columns": [ {"name": "transaction_id", "type": "LONG"}, {"name": "symbol", "type": "SYMBOL"}, {"name": "quantity", "type": "LONG"}, {"name": "rate", "type": "DOUBLE"} ], "dataset": [ [100004, "CITY", 500, 514.5], [100005, "CITY", 500, 517], [100006, "COAST", 200, 401] ], "count": 3, "done": false } ``` `DELETE /cursor/{{cursor}}` HTTP 200 (application/json) ```text {"cursor": "d8c238e3eace8c6c", "closed": true} ``` `GET /cursor/{{cursor}}?fetch=3` HTTP 404 (application/json) ```text {"error": "cursor 'd8c238e3eace8c6c' does not exist (an unused cursor closes after 10 minutes)"} ``` ## Limits of cursors Syntax: ```sql DECLARE c17 CURSOR FOR SELECT ... -- error when 16 cursors are open in the database ``` | Limit | Value | |---|---| | Open cursors in one database (SQL and HTTP together) | 16 (`DatabaseOptions::max_cursors`) | | A cursor without `FETCH` closes after | 10 minutes (`DatabaseOptions::cursor_idle_ms`) | | Rows in one cursor | 10 million. A bigger query is refused: narrow it with `WHERE`. | | Rows of one `FETCH` | the row limit of the request (for example `/exec` returns at most 1,000,000 rows) | | Memory | all rows of an open cursor are kept in memory until it closes | **Example: The 17th open cursor** (shows an error) ```sql DECLARE c1 CURSOR FOR SELECT 1 AS x; DECLARE c2 CURSOR FOR SELECT 2 AS x; DECLARE c3 CURSOR FOR SELECT 3 AS x; DECLARE c4 CURSOR FOR SELECT 4 AS x; DECLARE c5 CURSOR FOR SELECT 5 AS x; DECLARE c6 CURSOR FOR SELECT 6 AS x; DECLARE c7 CURSOR FOR SELECT 7 AS x; DECLARE c8 CURSOR FOR SELECT 8 AS x; DECLARE c9 CURSOR FOR SELECT 9 AS x; DECLARE c10 CURSOR FOR SELECT 10 AS x; DECLARE c11 CURSOR FOR SELECT 11 AS x; DECLARE c12 CURSOR FOR SELECT 12 AS x; DECLARE c13 CURSOR FOR SELECT 13 AS x; DECLARE c14 CURSOR FOR SELECT 14 AS x; DECLARE c15 CURSOR FOR SELECT 15 AS x; DECLARE c16 CURSOR FOR SELECT 16 AS x; DECLARE c17 CURSOR FOR SELECT 17 AS x; ``` Result: `DECLARE c1 CURSOR FOR SELECT 1 AS x` ```text cursor 'c1' declared: 1 row(s) ``` `DECLARE c2 CURSOR FOR SELECT 2 AS x` ```text cursor 'c2' declared: 1 row(s) ``` `DECLARE c3 CURSOR FOR SELECT 3 AS x` ```text cursor 'c3' declared: 1 row(s) ``` `DECLARE c4 CURSOR FOR SELECT 4 AS x` ```text cursor 'c4' declared: 1 row(s) ``` `DECLARE c5 CURSOR FOR SELECT 5 AS x` ```text cursor 'c5' declared: 1 row(s) ``` `DECLARE c6 CURSOR FOR SELECT 6 AS x` ```text cursor 'c6' declared: 1 row(s) ``` `DECLARE c7 CURSOR FOR SELECT 7 AS x` ```text cursor 'c7' declared: 1 row(s) ``` `DECLARE c8 CURSOR FOR SELECT 8 AS x` ```text cursor 'c8' declared: 1 row(s) ``` `DECLARE c9 CURSOR FOR SELECT 9 AS x` ```text cursor 'c9' declared: 1 row(s) ``` `DECLARE c10 CURSOR FOR SELECT 10 AS x` ```text cursor 'c10' declared: 1 row(s) ``` `DECLARE c11 CURSOR FOR SELECT 11 AS x` ```text cursor 'c11' declared: 1 row(s) ``` `DECLARE c12 CURSOR FOR SELECT 12 AS x` ```text cursor 'c12' declared: 1 row(s) ``` `DECLARE c13 CURSOR FOR SELECT 13 AS x` ```text cursor 'c13' declared: 1 row(s) ``` `DECLARE c14 CURSOR FOR SELECT 14 AS x` ```text cursor 'c14' declared: 1 row(s) ``` `DECLARE c15 CURSOR FOR SELECT 15 AS x` ```text cursor 'c15' declared: 1 row(s) ``` `DECLARE c16 CURSOR FOR SELECT 16 AS x` ```text cursor 'c16' declared: 1 row(s) ``` Error: `too many open cursors (16); CLOSE the ones you do not need (an unused cursor also closes after 10 minutes)`