# The procedure language > Variables, IF, WHILE, FOR, RETURN, RAISE, PRINT and cursors inside a procedure or trigger body. - Category: Stored procedures - Keywords: variable, variables, loop, THEN, DO, IN, INTO, OPEN, FOUND, EXECUTE, dynamic SQL, EXECUTE format, USING, INTO STRICT, table name in a variable, sp\_executesql, PREPARE, END IF, END WHILE, END FOR, FETCH INTO, OPEN cursor, declared twice, column name wins, WHILE loop ran, body, procedure body, trigger body - See also: [Stored procedures](procedures.md), [Triggers](triggers.md), [Cursors](cursors.md), [Transactions](transactions.md), [13. Functions, procedures and triggers](../tutorial/13-functions-procedures-triggers.md) These statements work only inside the body (`BEGIN ... END`) of a [procedure](procedures.md) or a [trigger](triggers.md). Every statement ends with `;`. | Statement | What it does | |---|---| | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | as usual. A `SELECT` returns its rows as an extra result of the `CALL`. | | `CALL other(...)` | runs another procedure (at most 16 levels deep) | | `SHOW ...`, `EXPLAIN SELECT ...` | as usual; the rows are an extra result | | [`DECLARE name TYPE [DEFAULT expr]`](procedure-language.md#declare) | makes a variable | | [`SET name = expr`](procedure-language.md#set) | gives a variable a new value | | [`IF ... THEN ... ELSIF ... ELSE ... END IF`](procedure-language.md#if) | chooses what to run | | [`WHILE cond DO ... END WHILE`](procedure-language.md#while) | repeats while a condition is true | | [`FOR r IN (SELECT ...) DO ... END FOR`](procedure-language.md#for) | runs once for every row of a query | | [`RETURN`](procedure-language.md#return) | stops the procedure; its changes are kept | | [`RAISE expr`](procedure-language.md#raise) | stops with an error; **everything is undone** | | [`PRINT expr`](procedure-language.md#print) | adds a line to the `print` result | | [`EXECUTE text [INTO v, ...] [USING a, ...]`](procedure-language.md#execute) | runs SQL made while the procedure runs (dynamic SQL) | | [`DECLARE c CURSOR FOR SELECT`, `OPEN`, `FETCH ... INTO`, `CLOSE`](procedure-language.md#local-cursors) | reads a query one row at a time | Not allowed in a body: `CREATE`, `DROP`, `ALTER`, `RENAME`, `BACKUP`, `BEGIN`, `COMMIT`, `ROLLBACK`. **Example: Brokerage fee of a stock trade, with variables and IF** ```sql CREATE PROCEDURE fee_of(amount_in DECIMAL(2)) AS BEGIN DECLARE rate DOUBLE; DECLARE fee DECIMAL(2); IF amount_in <= 50000 THEN SET rate = 0.0036; ELSIF amount_in <= 500000 THEN SET rate = 0.0033; ELSE SET rate = 0.0027; END IF; SET fee = round(greatest(amount_in * rate, 10), 2); PRINT concat('amount ', amount_in, ': rate ', rate * 100, '%, fee ', fee); END; CALL fee_of(1500); CALL fee_of(258500); CALL fee_of(881000); ``` Result: `CREATE PROCEDURE fee_of(amount_in DECIMAL(2)) AS BEGIN` ```text procedure 'fee_of' created ``` `CALL fee_of(1500)` ```text procedure 'fee_of' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | amount 1500.00: rate 0.36%, fee 10.00 | (1 row) `CALL fee_of(258500)` ```text procedure 'fee_of' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | amount 258500.00: rate 0.33%, fee 853.05 | (1 row) `CALL fee_of(881000)` ```text procedure 'fee_of' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | amount 881000.00: rate 0.27%, fee 2378.70 | (1 row) ## DECLARE Syntax: ```sql DECLARE name TYPE DECLARE name TYPE DEFAULT expression ``` Makes a variable: a named place for one value, like `have` or `total`. | Part | Meaning | |---|---| | `name` | the variable name. Upper and lower case are the same. | | `TYPE` | any column type: `INT`, `LONG`, `DOUBLE`, `DECIMAL(2)`, `STRING`, `SYMBOL`, `BOOL`, `DATE`, `TIMESTAMP`, ... | | `DEFAULT expression` | the first value. It may use parameters, earlier variables and a subquery `(SELECT ...)`. Without `DEFAULT`, the value is `NULL`. | - **Notes:** - Use a variable by its name in any expression: `WHERE id = from_id`, `VALUES (day, total)`. - Parameters are variables too: `SET` can change them. - A value is changed to the variable's type. A value that does not fit is an error (`'abc' is not a whole number`). - **A column name wins over a variable with the same name.** Inside `SELECT ... FROM securities`, the name `name` means the column, not your variable. Give variables names that are not column names. - A variable lives until the end of the procedure (not only until the end of an `IF` or loop). A `DECLARE` inside a loop fails the second time round: `variable 'x' is declared twice`. Declare before the loop. - Every procedure and trigger already has the variable `FOUND` (see [cursors in a body](procedure-language.md#local-cursors)). You cannot declare it. - `DECLARE name CURSOR FOR SELECT ...` makes a cursor, not a variable. **Example: Variables of many types, with and without DEFAULT** ```sql CREATE PROCEDURE show_types() AS BEGIN DECLARE qty INT DEFAULT 500; DECLARE rate DOUBLE DEFAULT 514.5; DECLARE amount_npr DECIMAL(2) DEFAULT qty * rate; DECLARE symbol_name SYMBOL DEFAULT 'CITY'; DECLARE trade_day DATE DEFAULT '2026-09-10'; DECLARE note STRING; PRINT concat(qty, ' x ', rate, ' = ', amount_npr, ' for ', symbol_name, ' on ', trade_day); PRINT coalesce(note, 'note is NULL'); END; CALL show_types(); ``` Result: `CREATE PROCEDURE show_types() AS BEGIN` ```text procedure 'show_types' created ``` `CALL show_types()` ```text procedure 'show_types' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | 500 x 514.5 = 257250.00 for CITY on 2026-09-10 | | note is NULL | (2 rows) **Example: The trap: a column name wins over a variable** (sample data: sample) ```sql CREATE PROCEDURE count_sector_wrong(name STRING) AS BEGIN SELECT count(*) AS shares FROM securities WHERE sector = name; END; CREATE PROCEDURE count_sector(wanted STRING) AS BEGIN SELECT count(*) AS shares FROM securities WHERE sector = wanted; END; CALL count_sector_wrong('Banking'); CALL count_sector('Banking'); ``` Result: `CREATE PROCEDURE count_sector_wrong(name STRING) AS BEGIN` ```text procedure 'count_sector_wrong' created ``` `CREATE PROCEDURE count_sector(wanted STRING) AS BEGIN` ```text procedure 'count_sector' created ``` `CALL count_sector_wrong('Banking')` ```text procedure 'count_sector_wrong' done: 0 row(s) changed ``` `SELECT count(*) AS shares FROM securities WHERE sector = name` | shares (LONG) | |---| | 0 | (1 row) `CALL count_sector('Banking')` ```text procedure 'count_sector' done: 0 row(s) changed ``` `SELECT count(*) AS shares FROM securities WHERE sector = wanted` | shares (LONG) | |---| | 2 | (1 row) **Example: DECLARE inside a loop** (shows an error) ```sql CREATE PROCEDURE p() AS BEGIN DECLARE i INT DEFAULT 0; WHILE i < 3 DO DECLARE doubled INT DEFAULT i * 2; SET i = i + 1; END WHILE; END; CALL p(); ``` Result: ```text procedure 'p' created ``` Error: `procedure 'p', line 4: variable 'doubled' is declared twice` ## SET Syntax: ```sql SET name = expression SET name = (SELECT one_value FROM ...) ``` Gives a variable (or a parameter) a new value. - **Notes:** - The variable must be declared first. - `SET x = (SELECT ...)` takes one value from a query. **No row** gives `NULL`. **More than one row** is an error: add `WHERE` or `LIMIT 1`. - The value is changed to the variable's type, like `CAST`. - A `DECIMAL(2)` variable refuses a value with more decimals (`1000.105 has more than 2 decimal places`). It does not round by itself: use `round(value, 2)`. - The query sees the changes made earlier in the same `CALL`. - `UPDATE` and `DELETE` may hold a subquery. Reading the value into a variable with `SET` first is often clearer, and lets you use it more than once. For many rows, use a [`FOR`](procedure-language.md#for) loop. **Example: Read a balance into a variable, then change it** ```sql CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL DEFAULT 0); INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0); CREATE PROCEDURE check_balance(account INT) AS BEGIN DECLARE have DECIMAL(2); SET have = (SELECT balance FROM accounts WHERE id = account); PRINT concat('account ', account, ' has ', coalesce(CAST(have AS STRING), 'no row: NULL')); SET have = have + round(have * 0.00105, 2); PRINT concat('with interest: ', have); END; CALL check_balance(1); CALL check_balance(7); ``` Result: `CREATE TABLE accounts (` ```text table 'accounts' created ``` `INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0)` ```text 2 row(s) inserted ``` `CREATE PROCEDURE check_balance(account INT) AS BEGIN` ```text procedure 'check_balance' created ``` `CALL check_balance(1)` ```text procedure 'check_balance' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | account 1 has 1000.00 | | with interest: 1001.05 | (2 rows) `CALL check_balance(7)` ```text procedure 'check_balance' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | account 7 has no row: NULL | | with interest: | (2 rows) **Example: A subquery inside UPDATE: use a variable instead** ```sql CREATE TABLE topics (id INT PRIMARY KEY, posts LONG NOT NULL DEFAULT 0); CREATE TABLE posts (id LONG PRIMARY KEY, topic_id INT NOT NULL); INSERT INTO topics (id) VALUES (1); INSERT INTO posts VALUES (1, 1), (2, 1), (3, 1); CREATE PROCEDURE recount(topic INT) AS BEGIN DECLARE n LONG; SET n = (SELECT count(*) FROM posts WHERE topic_id = topic); UPDATE topics SET posts = n WHERE id = topic; END; CALL recount(1); SELECT * FROM topics; ``` Result: `CREATE TABLE topics (` ```text table 'topics' created ``` `CREATE TABLE posts (` ```text table 'posts' created ``` `INSERT INTO topics (id) VALUES (1)` ```text 1 row(s) inserted ``` `INSERT INTO posts VALUES (1, 1), (2, 1), (3, 1)` ```text 3 row(s) inserted ``` `CREATE PROCEDURE recount(topic INT) AS BEGIN` ```text procedure 'recount' created ``` `CALL recount(1)` ```text procedure 'recount' done: 1 row(s) changed ``` `SELECT * FROM topics` | id (INT) | posts (LONG) | |---|---| | 1 | 3 | (1 row) **Example: The same with the subquery inside UPDATE** ```sql CREATE TABLE topics (id INT PRIMARY KEY, posts LONG NOT NULL DEFAULT 0); CREATE TABLE posts (id LONG PRIMARY KEY, topic_id INT NOT NULL); INSERT INTO topics (id) VALUES (1); INSERT INTO posts VALUES (1, 1), (2, 1), (3, 1); CREATE PROCEDURE recount(topic INT) AS BEGIN UPDATE topics SET posts = (SELECT count(*) FROM posts WHERE topic_id = topic) WHERE id = topic; END; CALL recount(1); SELECT * FROM topics; ``` Result: `CREATE TABLE topics (` ```text table 'topics' created ``` `CREATE TABLE posts (` ```text table 'posts' created ``` `INSERT INTO topics (id) VALUES (1)` ```text 1 row(s) inserted ``` `INSERT INTO posts VALUES (1, 1), (2, 1), (3, 1)` ```text 3 row(s) inserted ``` `CREATE PROCEDURE recount(topic INT) AS BEGIN` ```text procedure 'recount' created ``` `CALL recount(1)` ```text procedure 'recount' done: 1 row(s) changed ``` `SELECT * FROM topics` | id (INT) | posts (LONG) | |---|---| | 1 | 3 | (1 row) **Example: A variable that is not declared** (shows an error) ```sql CREATE PROCEDURE p() AS BEGIN SET total = 1; END; CALL p(); ``` Result: ```text procedure 'p' created ``` Error: `procedure 'p', line 2: variable 'total' is not declared (DECLARE total first)` **Example: DECIMAL(2) does not round by itself** (shows an error) ```sql CREATE PROCEDURE p() AS BEGIN DECLARE have DECIMAL(2) DEFAULT 1000; SET have = have + 0.105; END; CALL p(); ``` Result: ```text procedure 'p' created ``` Error: `procedure 'p', line 3: variable 'have' (DECIMAL(2)): 1000.105 has more than 2 decimal places` **Example: A query that gives two rows** (shows an error) ```sql CREATE TABLE accounts (id INT PRIMARY KEY, balance DECIMAL(2)); INSERT INTO accounts VALUES (1, 1000), (2, 0); CREATE PROCEDURE p() AS BEGIN DECLARE have DECIMAL(2); SET have = (SELECT balance FROM accounts); END; CALL p(); ``` Result: `CREATE TABLE accounts (` ```text table 'accounts' created ``` `INSERT INTO accounts VALUES (1, 1000), (2, 0)` ```text 2 row(s) inserted ``` `CREATE PROCEDURE p() AS BEGIN` ```text procedure 'p' created ``` Error: `procedure 'p', line 3 col 14: subquery returned 2 rows; a value needs exactly one (add LIMIT 1?)` ## IF, ELSIF, ELSEIF, ELSE Syntax: ```sql IF condition THEN statements; [ELSIF condition THEN statements;] ... [ELSE statements;] END IF ``` Runs the statements of the **first** condition that is true. When no condition is true, it runs the `ELSE` part (if there is one). `ELSEIF` is the same as `ELSIF`. - **Notes:** - The condition must be `TRUE` or `FALSE`: `IF 1 THEN` is an error. - A `NULL` condition counts as **false**, like in `WHERE`. - `EXISTS (SELECT ...)` and `(SELECT ...) > 0` work as conditions. - End it with `END IF;` (not only `END;`). **Example: NULL is not true: the ELSE part runs** ```sql CREATE PROCEDURE p(x INT) AS BEGIN IF x > 0 THEN PRINT concat(x, ' is positive'); ELSEIF x <= 0 THEN PRINT concat(x, ' is zero or negative'); ELSE PRINT 'x is NULL: both conditions are not true'; END IF; END; CALL p(5); CALL p(-2); CALL p(NULL); ``` Result: `CREATE PROCEDURE p(x INT) AS BEGIN` ```text procedure 'p' created ``` `CALL p(5)` ```text procedure 'p' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | 5 is positive | (1 row) `CALL p(-2)` ```text procedure 'p' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | -2 is zero or negative | (1 row) `CALL p(NULL)` ```text procedure 'p' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | x is NULL: both conditions are not true | (1 row) **Example: A condition with EXISTS** (sample data: sample) ```sql CREATE PROCEDURE has_trades(wanted SYMBOL, day DATE) AS BEGIN IF EXISTS (SELECT 1 FROM trade_log WHERE symbol = wanted AND date = day) THEN PRINT concat(wanted, ' traded on ', day); ELSE PRINT concat(wanted, ' did not trade on ', day); END IF; END; CALL has_trades('VOLT', '2026-09-10'); CALL has_trades('VOLT', '2026-09-09'); ``` Result: `CREATE PROCEDURE has_trades(wanted SYMBOL, day DATE) AS BEGIN` ```text procedure 'has_trades' created ``` `CALL has_trades('VOLT', '2026-09-10')` ```text procedure 'has_trades' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | VOLT traded on 2026-09-10 | (1 row) `CALL has_trades('VOLT', '2026-09-09')` ```text procedure 'has_trades' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | VOLT did not trade on 2026-09-09 | (1 row) **Example: A number is not a condition** (shows an error) ```sql CREATE PROCEDURE p() AS BEGIN IF 1 THEN PRINT 'yes'; END IF; END; CALL p(); ``` Result: ```text procedure 'p' created ``` Error: `procedure 'p', line 2: the condition 1 must be TRUE or FALSE, but it is 1` **Example: END without IF** (shows an error) ```sql CREATE PROCEDURE p(x INT) AS BEGIN IF x > 0 THEN PRINT 'positive'; END; END; ``` Result: Error: `line 4, column 6: expected IF after END to close the IF that starts at line 2 but found ';'` ## WHILE Syntax: ```sql WHILE condition DO statements; END WHILE ``` Runs the statements again and again, as long as the condition is true. The condition is checked before each round. - **Notes:** - A `NULL` condition counts as false: the loop stops. - A loop stops with an error after **10 million rounds**, so a loop that never ends cannot hold the database for ever. The whole `CALL` is then undone. - `RETURN` inside the loop stops the whole procedure. - To read rows one by one, a [`FOR`](procedure-language.md#for) loop is shorter. **Example: Savings: how many years until 1 lakh becomes 2 lakh at 8%?** ```sql CREATE PROCEDURE years_to_double(start DECIMAL(2), rate DOUBLE) AS BEGIN DECLARE money DECIMAL(2) DEFAULT start; DECLARE years INT DEFAULT 0; WHILE money < start * 2 DO SET money = round(money * (1 + rate), 2); SET years = years + 1; END WHILE; PRINT concat(years, ' years: ', money); END; CALL years_to_double(100000, 0.08); ``` Result: `CREATE PROCEDURE years_to_double(start DECIMAL(2), rate DOUBLE) AS BEGIN` ```text procedure 'years_to_double' created ``` `CALL years_to_double(100000, 0.08)` ```text procedure 'years_to_double' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | 10 years: 215892.49 | (1 row) **Example: A loop that never ends is stopped** (shows an error) ```sql CREATE PROCEDURE forever() AS BEGIN DECLARE i LONG DEFAULT 0; WHILE true DO SET i = i + 1; END WHILE; END; CALL forever(); ``` Result: ```text procedure 'forever' created ``` Error: `procedure 'forever', line 3: the WHILE loop ran 10000000 times and was stopped (does its condition ever become false?)` ## FOR Syntax: ```sql FOR row_name IN (SELECT ...) DO statements; -- use row_name.column END FOR ``` Runs the query once, then runs the statements once for every row. `row_name.column` is a value of the current row. | Part | Meaning | |---|---| | `row_name` | a name for the current row, like `r` or `c` | | `(SELECT ...)` | any query, in brackets. Give computed columns a name with `AS` (`sum(volume) AS volume`), so you can write `r.volume`. | - **Notes:** - The query runs **once**, before the first round. Changes that the loop makes to the same table do not add rows to the loop. - A column that the row does not have is an error that lists the columns it has. - In a trigger, `FOR r IN (SELECT ... FROM NEW) DO` goes over the changed rows (see [NEW, OLD](triggers.md#new-old)). - FOR loops can be nested; use different row names. - `RETURN` inside the loop stops the whole procedure. **Example: Market data: daily candles from the trade log** (sample data: sample) ```sql CREATE PROCEDURE candles_from_trade_log(day DATE) AS BEGIN DECLARE made INT DEFAULT 0; FOR t IN (SELECT symbol, first(rate) AS open, max(rate) AS high, min(rate) AS low, last(rate) AS close, sum(quantity) AS volume FROM trade_log WHERE date = day GROUP BY symbol) DO INSERT OR REPLACE INTO ochl_day VALUES (day, t.symbol, t.open, t.high, t.low, t.close, t.volume); SET made = made + 1; END FOR; PRINT concat(made, ' daily candles made for ', day); END; CALL candles_from_trade_log('2026-09-09'); SELECT time, symbol, open, high, low, close, volume FROM ochl_day WHERE time IN '2026-09-09' AND symbol IN ('CITY', 'COAST', 'TELCO') ORDER BY symbol; ``` Result: `CREATE PROCEDURE candles_from_trade_log(day DATE) AS BEGIN` ```text procedure 'candles_from_trade_log' created ``` `CALL candles_from_trade_log('2026-09-09')` ```text procedure 'candles_from_trade_log' done: 3 row(s) changed ``` `print` | output (STRING) | |---| | 3 daily candles made for 2026-09-09 | (1 row) `SELECT time, symbol, open, high, low, close, volume FROM ochl_day WHERE time IN '2026-09-09' AND sym` | time (TIMESTAMP) | symbol (SYMBOL) | open (DOUBLE) | high (DOUBLE) | low (DOUBLE) | close (DOUBLE) | volume (DOUBLE) | |---|---|---|---|---|---|---| | 2026-09-09T00:00:00.000Z | CITY | 515 | 517 | 513 | 517 | 1510 | | 2026-09-09T00:00:00.000Z | COAST | 401 | 401 | 398.5 | 398.5 | 220 | | 2026-09-09T00:00:00.000Z | TELCO | 881 | 881 | 879 | 879 | 2000 | (3 rows) **Example: Month-end interest for every account, one row at a time** ```sql CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL DEFAULT 0); CREATE TABLE interest_paid (account INT, amount DECIMAL(2), paid TIMESTAMP DEFAULT now()); INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0), (3, 'Hari', 25000); CREATE PROCEDURE pay_interest(yearly DOUBLE) AS BEGIN DECLARE interest DECIMAL(2); FOR a IN (SELECT id, balance FROM accounts WHERE balance > 0 ORDER BY id) DO SET interest = round(a.balance * yearly / 12, 2); UPDATE accounts SET balance = balance + interest WHERE id = a.id; INSERT INTO interest_paid (account, amount) VALUES (a.id, interest); END FOR; END; CALL pay_interest(0.06); SELECT id, owner, balance FROM accounts ORDER BY id; ``` Result: `CREATE TABLE accounts (` ```text table 'accounts' created ``` `CREATE TABLE interest_paid (` ```text table 'interest_paid' created ``` `INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0), (3, 'Hari', 25000)` ```text 3 row(s) inserted ``` `CREATE PROCEDURE pay_interest(yearly DOUBLE) AS BEGIN` ```text procedure 'pay_interest' created ``` `CALL pay_interest(0.06)` ```text procedure 'pay_interest' done: 4 row(s) changed ``` `SELECT id, owner, balance FROM accounts ORDER BY id` | id (INT) | owner (STRING) | balance (DECIMAL(2)) | |---|---|---| | 1 | Ram | 1005.00 | | 2 | Sita | 0.00 | | 3 | Hari | 25125.00 | (3 rows) **Example: A column the row does not have** (shows an error) ```sql CREATE PROCEDURE p() AS BEGIN FOR r IN (SELECT 'CITY' AS symbol, 514.5 AS ltp) DO PRINT r.price; END FOR; END; CALL p(); ``` Result: ```text procedure 'p' created ``` Error: `procedure 'p', line 3 col 11: row 'r' has no column 'price' (it has: symbol ltp)` ## RETURN Syntax: ```sql RETURN ``` Stops the procedure at once. The statements after it do not run. The changes made **before** `RETURN` are kept (unlike `RAISE`). - **Notes:** `RETURN` has no value: a procedure does not return a value. To give something back, use `SELECT` (an extra result) or `PRINT`. In a procedure called by another one, `RETURN` stops only the inner procedure. **Example: Stop early when there is nothing to do** ```sql CREATE TABLE orders (id INT PRIMARY KEY, status STRING); INSERT INTO orders VALUES (1, 'open'), (2, 'done'); CREATE PROCEDURE close_open_orders() AS BEGIN IF NOT EXISTS (SELECT 1 FROM orders WHERE status = 'open') THEN PRINT 'no open orders'; RETURN; END IF; UPDATE orders SET status = 'done' WHERE status = 'open'; PRINT 'open orders closed'; END; CALL close_open_orders(); CALL close_open_orders(); SELECT * FROM orders ORDER BY id; ``` Result: `CREATE TABLE orders (` ```text table 'orders' created ``` `INSERT INTO orders VALUES (1, 'open'), (2, 'done')` ```text 2 row(s) inserted ``` `CREATE PROCEDURE close_open_orders() AS BEGIN` ```text procedure 'close_open_orders' created ``` `CALL close_open_orders()` ```text procedure 'close_open_orders' done: 1 row(s) changed ``` `print` | output (STRING) | |---| | open orders closed | (1 row) `CALL close_open_orders()` ```text procedure 'close_open_orders' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | no open orders | (1 row) `SELECT * FROM orders ORDER BY id` | id (INT) | status (STRING) | |---|---| | 1 | done | | 2 | done | (2 rows) ## RAISE Syntax: ```sql RAISE expression ``` Stops with an error. The text of the error is the value of the expression. **Everything the `CALL` changed is undone** (and the whole transaction, when the `CALL` runs inside `BEGIN ... COMMIT`). In a `BEFORE` trigger, `RAISE` refuses the change. - **Returns:** the error `procedure 'name', line N: ` (or `trigger 'name', line N: ...`). `line N` is the line in the definition that [SHOW PROCEDURES](procedures.md#show-procedures) prints. - **Notes:** the expression can be any value: `RAISE 'text'`, `RAISE concat('account ', id, ' is closed')`, even a number. The error's SQLSTATE is always `P0001` (you cannot choose another here), and there is no way to catch an error inside the procedure. **Example: Accounting: never let a balance go below zero** (shows an error) ```sql CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL DEFAULT 0); INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0); CREATE PROCEDURE withdraw(account INT, amt DECIMAL(2)) AS BEGIN UPDATE accounts SET balance = balance - amt WHERE id = account; IF (SELECT balance FROM accounts WHERE id = account) < 0 THEN RAISE concat('not enough money in account ', account, ' for ', amt); END IF; END; CALL withdraw(1, 400); CALL withdraw(1, 700); ``` Result: `CREATE TABLE accounts (` ```text table 'accounts' created ``` `INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0)` ```text 2 row(s) inserted ``` `CREATE PROCEDURE withdraw(account INT, amt DECIMAL(2)) AS BEGIN` ```text procedure 'withdraw' created ``` `CALL withdraw(1, 400)` ```text procedure 'withdraw' done: 1 row(s) changed ``` Error: `procedure 'withdraw', line 4: not enough money in account 1 for 700.00` The first `CALL` was saved. The second one took 700 first, then `RAISE` undid it. So Ram has 600: **Command line: After the RAISE: only the first withdrawal is saved** (shows an error) Setup: ```text CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL DEFAULT 0); INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0); CREATE PROCEDURE withdraw(account INT, amt DECIMAL(2)) AS BEGIN UPDATE accounts SET balance = balance - amt WHERE id = account; IF (SELECT balance FROM accounts WHERE id = account) < 0 THEN RAISE concat('not enough money in account ', account, ' for ', amt); END IF; END; ``` ```bash kdb sql market "CALL withdraw(1, 400)" kdb sql market "CALL withdraw(1, 700)" kdb sql market "SELECT id, owner, balance FROM accounts ORDER BY id" ``` Output: `> kdb sql market "CALL withdraw(1, 400)"` ```text procedure 'withdraw' done: 1 row(s) changed ``` `> kdb sql market "CALL withdraw(1, 700)"` ```text error: procedure 'withdraw', line 4: not enough money in account 1 for 700.00 ``` exit code 1 `> kdb sql market "SELECT id, owner, balance FROM accounts ORDER BY id"` ```text id | owner | balance ---+-------+-------- 1 | Ram | 600.00 2 | Sita | 0.00 (2 rows, 0.3 ms) ``` ## PRINT Syntax: ```sql PRINT expression ``` Adds one line to a result called `print` (one column, `output`). Use it for messages and for debugging. - **Returns:** after the other results of the `CALL` (or of the statement that fired a trigger), a result `print` with all `PRINT` lines in order. - **Notes:** - The value is written as text. `NULL` is written as `NULL`. - With `CALL ... WITH DEBUG`, the lines are in the `debug` result instead. - When the `CALL` fails, the lines from before the error are still returned. **Example: Sign-up report for the app admin** ```sql CREATE TABLE app_users (id INT PRIMARY KEY AUTO, email STRING NOT NULL, created TIMESTAMP DEFAULT now()); INSERT INTO app_users (email) VALUES ('ram@example.com'), ('sita@example.com'), ('hari@example.com'); CREATE PROCEDURE signup_report() AS BEGIN PRINT concat('users: ', (SELECT count(*) FROM app_users)); PRINT concat('newest: ', (SELECT email FROM app_users ORDER BY id DESC LIMIT 1)); PRINT NULL; PRINT 3.5; END; CALL signup_report(); ``` Result: `CREATE TABLE app_users (` ```text table 'app_users' created ``` `INSERT INTO app_users (email) VALUES ('ram@example.com'), ('sita@example.com'), ('hari@example.com')` ```text 3 row(s) inserted ``` `CREATE PROCEDURE signup_report() AS BEGIN` ```text procedure 'signup_report' created ``` `CALL signup_report()` ```text procedure 'signup_report' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | users: 3 | | newest: hari@example.com | | NULL | | 3.5 | (4 rows) ## EXECUTE (dynamic SQL) Syntax: ```sql EXECUTE text; EXECUTE text INTO variable, ...; EXECUTE text INTO STRICT variable, ...; EXECUTE text [INTO ...] USING value, ...; ``` Runs SQL that is made while the procedure runs. Use it when a table or column name comes from an argument. It works as in PostgreSQL PL/pgSQL. | Part | What it does | |---|---| | `text` | any expression that gives the SQL text, for example [format](functions-text.md#format)`(...)` or `'...' \|\| name` | | `USING a, b` | the values of `$1`, `$2`, ... in the text. They go in as **values**, never as SQL. | | `INTO v, w` | puts the first row into the variables (one variable for each column). No row: the variables become `NULL`. | | `INTO STRICT v` | the same, but exactly one row is needed; no row or more rows is an error | - **Notes:** - Put values in with `USING`, or with `%L` of `format`. Put names in with `%I` of `format` or `quote_ident`. Then a quote in a value cannot change the statement. - The text may hold `SELECT`, `INSERT`, `UPDATE`, `DELETE` (with `RETURNING` for `INTO`), `CALL`, `EXPLAIN` or `SHOW`: what a body may hold. `CREATE`, `DROP`, `BEGIN` and `COMMIT` are refused. - The statement runs with the **rights of the user who called** the procedure, inside the same transaction. An error undoes the whole `CALL`. - `FOUND` is `TRUE` when the statement gave or changed at least one row. - A `SELECT` without `INTO` returns its rows as an extra result of the `CALL`. - Speed: a date in `USING` skips partitions like a date written in the SQL. `CALL ... WITH DEBUG` shows the text that ran and `partitions read`. - The text is parsed each time it runs. In a loop over many rows, a normal statement with variables is faster. - Locks: KDB locks the tables a `CALL` uses before it starts. Text written out in the body (`EXECUTE 'INSERT INTO t VALUES ($1)' USING x`) is known then, so only its tables are locked. Text made while the procedure runs (`format(...)`, `'...' || name`) could name any table, so such a `CALL` (or trigger) locks **every** table of the database while it runs, and writers of all other tables wait for it. See [one writer per table](transactions.md#one-writer). **Example: One procedure for two tables: the table name is an argument** ```sql CREATE TABLE daily_prices (symbol STRING, close DOUBLE); CREATE TABLE weekly_prices (symbol STRING, close DOUBLE); CREATE PROCEDURE add_price(tbl STRING, sym STRING, px DOUBLE) AS BEGIN EXECUTE format('INSERT INTO %I VALUES ($1, $2)', tbl) USING sym, px; END; CALL add_price('daily_prices', 'CITY', 512.5); CALL add_price('weekly_prices', 'CITY', 509.0); CALL add_price('daily_prices', 'it''s; DROP TABLE daily_prices', 1); SELECT * FROM daily_prices; ``` Result: `CREATE TABLE daily_prices (` ```text table 'daily_prices' created ``` `CREATE TABLE weekly_prices (` ```text table 'weekly_prices' created ``` `CREATE PROCEDURE add_price(tbl STRING, sym STRING, px DOUBLE) AS BEGIN` ```text procedure 'add_price' created ``` `CALL add_price('daily_prices', 'CITY', 512.5)` ```text procedure 'add_price' done: 1 row(s) changed ``` `CALL add_price('weekly_prices', 'CITY', 509.0)` ```text procedure 'add_price' done: 1 row(s) changed ``` `CALL add_price('daily_prices', 'it''s; DROP TABLE daily_prices', 1)` ```text procedure 'add_price' done: 1 row(s) changed ``` `SELECT * FROM daily_prices` | symbol (STRING) | close (DOUBLE) | |---|---| | CITY | 512.5 | | it's; DROP TABLE daily_prices | 1 | (2 rows) **Example: A count into a variable** ```sql CREATE TABLE app_users (id INT PRIMARY KEY AUTO, email STRING); INSERT INTO app_users (email) VALUES ('ram@example.com'), ('sita@example.com'); CREATE PROCEDURE row_count(tbl STRING) AS BEGIN DECLARE n LONG; EXECUTE 'SELECT count(*) FROM ' || quote_ident(tbl) INTO n; PRINT concat(tbl, ': ', n, ' rows'); END; CALL row_count('app_users'); ``` Result: `CREATE TABLE app_users (` ```text table 'app_users' created ``` `INSERT INTO app_users (email) VALUES ('ram@example.com'), ('sita@example.com')` ```text 2 row(s) inserted ``` `CREATE PROCEDURE row_count(tbl STRING) AS BEGIN` ```text procedure 'row_count' created ``` `CALL row_count('app_users')` ```text procedure 'row_count' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | app_users: 2 rows | (1 row) **Example: INTO STRICT needs exactly one row** (shows an error) ```sql CREATE TABLE app_users (id INT PRIMARY KEY, email STRING); CREATE PROCEDURE email_of(user_id INT) AS BEGIN DECLARE e STRING; EXECUTE 'SELECT email FROM app_users WHERE id = $1' INTO STRICT e USING user_id; END; CALL email_of(7); ``` Result: `CREATE TABLE app_users (` ```text table 'app_users' created ``` `CREATE PROCEDURE email_of(user_id INT) AS BEGIN` ```text procedure 'email_of' created ``` Error: `procedure 'email_of', line 3: EXECUTE ... INTO STRICT: the query gave no row` **Example: Only the statements a body may hold** (shows an error) ```sql CREATE PROCEDURE make_table(tbl STRING) AS BEGIN EXECUTE format('CREATE TABLE %I (a INT)', tbl); END; CALL make_table('t2'); ``` Result: ```text procedure 'make_table' created ``` Error: `procedure 'make_table', line 2: EXECUTE runs SELECT, INSERT, UPDATE, DELETE, CALL, EXPLAIN or SHOW, the statements a procedure body may hold; not CREATE TABLE t2 ( a INT )` ## Cursors in a body: DECLARE CURSOR, OPEN, FETCH INTO, CLOSE Syntax: ```sql DECLARE cursor_name CURSOR FOR SELECT ...; OPEN cursor_name; FETCH cursor_name INTO variable, variable, ...; FETCH [NEXT] [FROM] cursor_name INTO variable, ...; CLOSE cursor_name; FOUND -- TRUE when the last FETCH read a row ``` A cursor reads a query **one row at a time**. | Statement | What it does | |---|---| | `DECLARE c CURSOR FOR SELECT ...` | names the query. It does not run yet. | | `OPEN c` | runs the query and keeps its rows | | `FETCH c INTO a, b` | puts the next row into the variables (one variable for each column) and sets `FOUND` | | `CLOSE c` | forgets the rows. You can `OPEN` it again (the query runs again). | - **Notes:** - `FOUND` is `TRUE` when `FETCH` read a row, and `FALSE` when there was no row left. When there is no row, the variables keep their old values. - `FETCH ... INTO` reads one row. `FETCH NEXT 5` and `FETCH ALL` are for [SQL cursors](cursors.md) outside a body. - `OPEN` of an open cursor, and `FETCH` of a cursor that is not open, are errors. - A cursor of a body belongs to that one `CALL`. It closes by itself when the procedure ends, and it does not count toward the 16 SQL cursors. - A `FOR` loop is shorter for one query. A cursor is useful when you read two queries side by side, or stop early. **Example: The biggest trade of a day, with a cursor loop** (sample data: sample) ```sql CREATE PROCEDURE biggest_trade(day DATE) AS BEGIN DECLARE sym SYMBOL; DECLARE qty LONG; DECLARE best_sym SYMBOL; DECLARE best_qty LONG DEFAULT 0; DECLARE trades CURSOR FOR SELECT symbol, quantity FROM trade_log WHERE date = day ORDER BY transaction_id; OPEN trades; FETCH trades INTO sym, qty; WHILE found DO IF qty > best_qty THEN SET best_qty = qty; SET best_sym = sym; END IF; FETCH trades INTO sym, qty; END WHILE; CLOSE trades; PRINT concat('biggest trade on ', day, ': ', best_qty, ' shares of ', best_sym); END; CALL biggest_trade('2026-09-10'); ``` Result: `CREATE PROCEDURE biggest_trade(day DATE) AS BEGIN` ```text procedure 'biggest_trade' created ``` `CALL biggest_trade('2026-09-10')` ```text procedure 'biggest_trade' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | biggest trade on 2026-09-10: 1000 shares of COAST | (1 row) **Example: FOUND after the last row** ```sql CREATE PROCEDURE p() AS BEGIN DECLARE n INT; DECLARE c CURSOR FOR SELECT 7 AS x; OPEN c; FETCH c INTO n; PRINT concat('found=', found, ', n=', n); FETCH c INTO n; PRINT concat('found=', found, ', n=', n, ' (the old value)'); CLOSE c; END; CALL p(); ``` Result: `CREATE PROCEDURE p() AS BEGIN` ```text procedure 'p' created ``` `CALL p()` ```text procedure 'p' done: 0 row(s) changed ``` `print` | output (STRING) | |---| | found=true, n=7 | | found=false, n=7 (the old value) | (2 rows) **Example: One variable for each column** (shows an error) ```sql CREATE PROCEDURE p() AS BEGIN DECLARE sym SYMBOL; DECLARE c CURSOR FOR SELECT 'CITY' AS symbol, 514.5 AS ltp; OPEN c; FETCH c INTO sym; END; CALL p(); ``` Result: ```text procedure 'p' created ``` Error: `procedure 'p', line 5: FETCH c INTO needs 2 variable(s), one for each column of the cursor (symbol, ltp), got 1` **Example: FETCH before OPEN** (shows an error) ```sql CREATE PROCEDURE p() AS BEGIN DECLARE n INT; DECLARE c CURSOR FOR SELECT 1 AS x; FETCH c INTO n; END; CALL p(); ``` Result: ```text procedure 'p' created ``` Error: `procedure 'p', line 4: cursor 'c' is not open (OPEN c first)` ## Limits of the procedure language | Limit | Value | |---|---| | Procedures calling procedures (also itself) | 16 levels | | Triggers firing triggers | 16 levels | | Rounds of one `WHILE` loop | 10 million | | Rows changed in one `CALL` (one transaction) | 10 million | | Steps kept by `WITH DEBUG` | 100,000 | | Text of one statement in a debug row | 200 characters | - **Notes:** there is no `LOOP`, `CASE` statement (the `CASE` expression works), `EXCEPTION` block, `EXIT` or `CONTINUE`. Use `WHILE` with a variable, and `RETURN`. **Example: A syntax error is found when you create the procedure** (shows an error) ```sql CREATE PROCEDURE p(x INT) AS BEGIN WHILE x > 0 SET x = x - 1; END WHILE; END; ``` Result: Error: `line 3, column 5: expected an operator (AND, OR, =, +, ...) or DO after the WHILE condition but found 'SET'`