# Stored procedures
> Save many SQL steps under one name and run them with CALL, as one transaction.
- Category: Stored procedures
- Keywords: procedure, stored procedure, CALL, PROCEDURES, DEBUG, WITH DEBUG, debug, print result, CREATE OR REPLACE PROCEDURE, one transaction, call each other too deeply, READ ONLY, read-only procedure, cannot be READ ONLY, --read-only-calls, KDB\_READ\_ONLY\_CALLS, rounding, 6 decimals, match old system
- See also: [The procedure language](procedure-language.md), [Triggers](triggers.md), [Transactions](transactions.md), [Saved functions](saved-functions.md), [13. Functions, procedures and triggers](../tutorial/13-functions-procedures-triggers.md)
A **stored procedure** (or just "procedure") is a list of SQL statements with a name. You run it with `CALL name(...)`.
KDB keeps it in the database, like a table. Your app, the web console and `kdb sql` can all call it.
- A procedure can have **variables**, `IF`, loops, cursors and errors (see [the procedure language](procedure-language.md)).
- **A `CALL` is one transaction** (a group of changes that are saved together, or not at all). If one step fails, nothing of the `CALL` is saved.
A real job: after the market closes, a trading app builds the daily candle of each share from the one-minute candles.
**Example: End of day: build the daily candles from the minute candles** (sample data: sample)
```sql
CREATE PROCEDURE end_of_day(day DATE) AS BEGIN
DECLARE minutes LONG DEFAULT 0;
SET minutes = (SELECT count(*) FROM ochl_minutes
WHERE time >= day AND time < dateadd('d', 1, day));
IF minutes = 0 THEN
RAISE concat('no minute candles for ', day);
END IF;
FOR c IN (SELECT symbol, first(open) AS open, max(high) AS high, min(low) AS low,
last(close) AS close, sum(volume) AS volume
FROM ochl_minutes
WHERE time >= day AND time < dateadd('d', 1, day)
GROUP BY symbol) DO
INSERT OR REPLACE INTO ochl_day VALUES (day, c.symbol, c.open, c.high, c.low, c.close, c.volume);
END FOR;
PRINT concat(minutes, ' minute candles read');
SELECT symbol, open, high, low, close, volume FROM ochl_day WHERE time = day ORDER BY symbol;
END;
CALL end_of_day('2026-09-10');
```
Result:
`CREATE PROCEDURE end_of_day(day DATE) AS BEGIN`
```text
procedure 'end_of_day' created
```
`CALL end_of_day('2026-09-10')`
```text
procedure 'end_of_day' done: 2 row(s) changed
```
`SELECT symbol, open, high, low, close, volume FROM ochl_day WHERE time = day ORDER BY symbol`
| symbol (SYMBOL) | open (DOUBLE) | high (DOUBLE) | low (DOUBLE) | close (DOUBLE) | volume (DOUBLE) |
|---|---|---|---|---|---|
| CITY | 504.6 | 507 | 500.7 | 504.9 | 183040 |
| COAST | 380.1 | 382 | 373.2 | 376.1 | 186200 |
| VOLT | 185.6 | 192.1 | 184.2 | 191.2 | 41470 |
(3 rows)
`print`
| output (STRING) |
|---|
| 476 minute candles read |
(1 row)
## CREATE PROCEDURE
Syntax:
```sql
CREATE [OR REPLACE] PROCEDURE name(parameter TYPE, ...) [READ ONLY] AS BEGIN
statement;
statement;
...
END
```
Saves a procedure. `OR REPLACE` changes one that already exists.
| Part | Meaning |
|---|---|
| `name` | the procedure name. Upper and lower case are the same. |
| `parameter TYPE` | an input value and its type, like `day DATE` or `amt DECIMAL(2)`. Write `()` for none. |
| `READ ONLY` | optional: the procedure changes no data. KDB refuses a body that does, and every `CALL` of it runs like a `SELECT`. See [a CALL that only reads](procedures.md#read-only-call). |
| `BEGIN ... END` | the body. Every statement in it ends with `;`. |
What a body can hold: `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `CALL`, `SHOW ...`, `EXPLAIN`, and the procedure statements `DECLARE`, `SET`, `IF`, `WHILE`, `FOR`, `OPEN`, `FETCH`, `CLOSE`, `RETURN`, `RAISE` and `PRINT` ([the procedure language](procedure-language.md)).
- **Returns:** `procedure 'name' created` (or `replaced`).
- **Notes:**
- `CREATE`, `DROP`, `ALTER`, `BACKUP`, `BEGIN`, `COMMIT` and `ROLLBACK` cannot be used inside a body. A `CALL` is already one transaction.
- KDB checks the **grammar** of the body when you create the procedure. Tables and columns are checked only when it runs, so a procedure can use a table that you create later.
- `found` cannot be a parameter name: every procedure already has a variable `FOUND`.
- **A column name wins over a parameter with the same name.** Give parameters names that are not column names (`amt`, not `amount`; `wanted`, not `name`).
- `CREATE PROCEDURE` cannot be used inside `BEGIN ... COMMIT`.
- In `kdb shell`, KDB knows that `CREATE PROCEDURE ... BEGIN ... END;` is one statement, even with `;` inside.
Setup used by the examples below:
```sql
-- A small bank: accounts, and a ledger with one row for every transfer.
CREATE TABLE accounts (
id INT PRIMARY KEY,
owner STRING NOT NULL,
balance DECIMAL(2) NOT NULL DEFAULT 0
);
CREATE TABLE ledger (
id LONG PRIMARY KEY AUTO,
from_account INT NOT NULL REFERENCES accounts(id),
to_account INT NOT NULL REFERENCES accounts(id),
amount DECIMAL(2) NOT NULL,
created TIMESTAMP DEFAULT now()
);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0), (3, 'Hari', 500);
```
**Example: Accounting: move money between two accounts (double entry)**
```sql
CREATE PROCEDURE transfer(from_id INT, to_id INT, amt DECIMAL(2)) AS BEGIN
DECLARE have DECIMAL(2);
IF amt <= 0 THEN
RAISE 'the amount must be more than 0';
END IF;
SET have = (SELECT balance FROM accounts WHERE id = from_id);
IF have IS NULL THEN
RAISE concat('account ', from_id, ' does not exist');
ELSIF have < amt THEN
RAISE concat('not enough money: account ', from_id, ' has ', have, ', the transfer needs ', amt);
END IF;
UPDATE accounts SET balance = balance - amt WHERE id = from_id;
UPDATE accounts SET balance = balance + amt WHERE id = to_id;
INSERT INTO ledger (from_account, to_account, amount) VALUES (from_id, to_id, amt);
PRINT concat('moved ', amt, ' from account ', from_id, ' to account ', to_id);
END;
CALL transfer(1, 2, 250.50);
SELECT id, owner, balance FROM accounts ORDER BY id;
```
Result:
`CREATE PROCEDURE transfer(from_id INT, to_id INT, amt DECIMAL(2)) AS BEGIN`
```text
procedure 'transfer' created
```
`CALL transfer(1, 2, 250.50)`
```text
procedure 'transfer' done: 3 row(s) changed
```
`print`
| output (STRING) |
|---|
| moved 250.50 from account 1 to account 2 |
(1 row)
`SELECT id, owner, balance FROM accounts ORDER BY id`
| id (INT) | owner (STRING) | balance (DECIMAL(2)) |
|---|---|---|
| 1 | Ram | 749.50 |
| 2 | Sita | 250.50 |
| 3 | Hari | 500.00 |
(3 rows)
**Example: Forum: a new post and its topic counter in one step**
```sql
CREATE TABLE topics (id INT PRIMARY KEY, title STRING NOT NULL, posts LONG NOT NULL DEFAULT 0);
CREATE TABLE posts (id LONG PRIMARY KEY AUTO, topic_id INT NOT NULL REFERENCES topics(id), author STRING, body STRING, created TIMESTAMP DEFAULT now());
INSERT INTO topics (id, title) VALUES (1, 'CITY quarterly results');
CREATE PROCEDURE add_post(topic INT, who STRING, text STRING) AS BEGIN
IF length(trim(text)) = 0 THEN
RAISE 'a post cannot be empty';
END IF;
INSERT INTO posts (topic_id, author, body) VALUES (topic, who, text);
UPDATE topics SET posts = posts + 1 WHERE id = topic;
END;
CALL add_post(1, 'ram', 'Profit is up 12%.');
CALL add_post(1, 'sita', 'Good for the dividend.');
SELECT id, title, posts FROM topics;
```
Result:
`CREATE TABLE topics (`
```text
table 'topics' created
```
`CREATE TABLE posts (`
```text
table 'posts' created
```
`INSERT INTO topics (id, title) VALUES (1, 'CITY quarterly results')`
```text
1 row(s) inserted
```
`CREATE PROCEDURE add_post(topic INT, who STRING, text STRING) AS BEGIN`
```text
procedure 'add_post' created
```
`CALL add_post(1, 'ram', 'Profit is up 12%.')`
```text
procedure 'add_post' done: 2 row(s) changed
```
`CALL add_post(1, 'sita', 'Good for the dividend.')`
```text
procedure 'add_post' done: 2 row(s) changed
```
`SELECT id, title, posts FROM topics`
| id (INT) | title (STRING) | posts (LONG) |
|---|---|---|
| 1 | CITY quarterly results | 2 |
(1 row)
**Example: Tables are checked when the procedure runs, not when it is created** (shows an error)
```sql
CREATE PROCEDURE count_archive() AS BEGIN
SELECT count(*) AS rows_in_archive FROM archive;
END;
CREATE TABLE audit (id INT PRIMARY KEY);
CALL count_archive();
```
Result:
`CREATE PROCEDURE count_archive() AS BEGIN`
```text
procedure 'count_archive' created
```
`CREATE TABLE audit (`
```text
table 'audit' created
```
Error: `procedure 'count_archive', line 2 col 43: table or view 'archive' does not exist`
**Example: BEGIN cannot be used inside a body** (shows an error)
```sql
CREATE PROCEDURE p() AS BEGIN
BEGIN;
UPDATE accounts SET balance = 0;
COMMIT;
END;
```
Result:
Error: `line 2, column 3: BEGIN cannot be used inside a procedure or trigger body: the whole CALL is one transaction (use RAISE to undo all of it)`
**Example: CREATE cannot be used inside a body** (shows an error)
```sql
CREATE PROCEDURE make_table() AS BEGIN
CREATE TABLE archive (id INT);
END;
```
Result:
Error: `line 2, column 3: CREATE cannot be used inside a procedure or trigger body; expected SELECT, INSERT, UPDATE, DELETE, CALL, DECLARE, SET, IF, WHILE, FOR, OPEN, FETCH, CLOSE, RETURN, RAISE, PRINT or NOTIFY`
**Example: The name is already used** (shows an error)
```sql
CREATE PROCEDURE hello() AS BEGIN PRINT 'hello'; END;
CREATE PROCEDURE hello() AS BEGIN PRINT 'namaste'; END;
```
Result:
```text
procedure 'hello' created
```
Error: `procedure 'hello' already exists; use CREATE OR REPLACE PROCEDURE to change it`
## CALL
Syntax:
```sql
CALL name(argument, ...)
CALL name()
CALL name(argument, ...) WITH DEBUG
```
Runs a procedure. Each argument can be a value or an expression, like `'2026-09-10'` or `dateadd('d', -1, now())`.
- **Returns:** more than one result:
1. the message `procedure 'name' done: N row(s) changed` (N counts the rows changed by `INSERT`, `UPDATE` and `DELETE` in the procedure and in the procedures it calls);
2. the rows of every `SELECT` in the procedure, in order;
3. a result called `print` with one row (column `output`) for each `PRINT`.
The web console, `kdb sql` and `POST /exec` show them like several statements.
- **Notes:**
- Each argument is changed to the type of its parameter: `CALL end_of_day('2026-09-10')` turns the text into a `DATE`. A value that does not fit is an error.
- The number of arguments must match.
- Inside the body a parameter or variable is a plain value. `WHERE time >= day` skips partitions like a written date, also with `dateadd('d', 1, day)` around it. See [performance tips](performance-tips.md#parameters).
- A procedure may `CALL` another procedure, or itself, at most **16 levels** deep.
- `CALL` is one transaction: see [A CALL is one transaction](procedures.md#call-transaction).
- When the `CALL` fails, the `print` lines from before the error are still returned, next to the error. Nothing that the procedure changed is saved.
- Add `WITH DEBUG` to see every step: [CALL ... WITH DEBUG](procedures.md#with-debug).
**Example: Transfer, then look at the books**
```sql
CREATE PROCEDURE transfer(from_id INT, to_id INT, amt DECIMAL(2)) AS BEGIN
IF (SELECT balance FROM accounts WHERE id = from_id) < amt THEN
RAISE concat('not enough money in account ', from_id);
END IF;
UPDATE accounts SET balance = balance - amt WHERE id = from_id;
UPDATE accounts SET balance = balance + amt WHERE id = to_id;
INSERT INTO ledger (from_account, to_account, amount) VALUES (from_id, to_id, amt);
SELECT id, owner, balance FROM accounts WHERE id IN (from_id, to_id) ORDER BY id;
PRINT concat('ledger rows now: ', (SELECT count(*) FROM ledger));
END;
CALL transfer(3, 2, 120);
```
Result:
`CREATE PROCEDURE transfer(from_id INT, to_id INT, amt DECIMAL(2)) AS BEGIN`
```text
procedure 'transfer' created
```
`CALL transfer(3, 2, 120)`
```text
procedure 'transfer' done: 3 row(s) changed
```
`SELECT id, owner, balance FROM accounts WHERE id IN (from_id, to_id) ORDER BY id`
| id (INT) | owner (STRING) | balance (DECIMAL(2)) |
|---|---|---|
| 2 | Sita | 120.00 |
| 3 | Hari | 380.00 |
(2 rows)
`print`
| output (STRING) |
|---|
| ledger rows now: 1 |
(1 row)
**Example: A text argument becomes a DATE** (sample data: sample)
```sql
CREATE PROCEDURE trades_of(day DATE) AS BEGIN
SELECT symbol, count(*) AS trades, sum(quantity) AS shares
FROM trade_log WHERE date = day GROUP BY symbol ORDER BY symbol;
END;
CALL trades_of('2026-09-09');
```
Result:
`CREATE PROCEDURE trades_of(day DATE) AS BEGIN`
```text
procedure 'trades_of' created
```
`CALL trades_of('2026-09-09')`
```text
procedure 'trades_of' done: 0 row(s) changed
```
`SELECT symbol, count(*) AS trades, sum(quantity) AS shares FROM trade_log WHERE date = day GROUP BY `
| symbol (SYMBOL) | trades (LONG) | shares (LONG) |
|---|---|---|
| CITY | 4 | 1510 |
| COAST | 2 | 220 |
| TELCO | 2 | 2000 |
(3 rows)
**Example: Wrong number of arguments** (shows an error)
```sql
CREATE PROCEDURE pay(to_id INT, amt DECIMAL(2)) AS BEGIN
UPDATE accounts SET balance = balance + amt WHERE id = to_id;
END;
CALL pay(2);
```
Result:
```text
procedure 'pay' created
```
Error: `procedure 'pay' takes 2 argument(s) (to_id INT, amt DECIMAL(2)), got 1`
**Example: An argument that does not fit the type** (shows an error)
```sql
CREATE PROCEDURE close_account(account INT) AS BEGIN
DELETE FROM accounts WHERE id = account;
END;
CALL close_account('Ram');
```
Result:
```text
procedure 'close_account' created
```
Error: `argument 1 of procedure 'close_account': variable 'account' (INT): 'Ram' is not a whole number`
**Example: No such procedure** (shows an error)
```sql
CALL no_such_procedure();
```
Result:
Error: `procedure 'no_such_procedure' does not exist (SHOW PROCEDURES lists them)`
**Example: A procedure that calls itself too often** (shows an error)
```sql
CREATE PROCEDURE countdown(n INT) AS BEGIN
PRINT n;
IF n > 0 THEN
CALL countdown(n - 1);
END IF;
END;
CALL countdown(3);
CALL countdown(20);
```
Result:
`CREATE PROCEDURE countdown(n INT) AS BEGIN`
```text
procedure 'countdown' created
```
`CALL countdown(3)`
```text
procedure 'countdown' done: 0 row(s) changed
```
`print`
| output (STRING) |
|---|
| 3 |
| 2 |
| 1 |
| 0 |
(4 rows)
`print`
| output (STRING) |
|---|
| 20 |
| 19 |
| 18 |
| 17 |
| 16 |
| 15 |
| 14 |
| 13 |
| 12 |
| 11 |
| 10 |
| 9 |
| 8 |
| 7 |
| 6 |
| 5 |
(16 rows)
Error: `procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedure 'countdown', line 4: procedures call each other too deeply (more than 16 levels, at procedure 'countdown')`
## A CALL is one transaction
Syntax:
```sql
CALL name(...) -- one transaction by itself
BEGIN; CALL a(...); CALL b(...); COMMIT; -- part of a bigger transaction
```
All changes of a `CALL` are saved **together** when it ends, with one write to the log (the file where KDB writes every change first).
If any statement fails, or `RAISE` runs, **nothing** of the `CALL` is saved, also not the steps before the error.
- Statements inside the procedure see the changes made earlier in the same `CALL`.
- Inside `BEGIN ... COMMIT`, a `CALL` is part of that bigger transaction. An error in the `CALL` rolls back (undoes) the whole transaction. See [transactions](transactions.md).
- Triggers that the procedure fires are part of the same transaction too.
- At most 10 million changed rows in one transaction.
- Before it starts, a `CALL` locks every table its body (and the procedures and triggers it sets off) reads or changes, until it ends. Writers of other tables go on meanwhile; writers of these tables wait. A body with [`EXECUTE`](procedure-language.md#execute) of text made while it runs locks every table. See [one writer per table](transactions.md#one-writer).
- A long `CALL` lets readers of its tables in between its statements ([reader yield](transactions.md#reader-yield), on unless the server turns it off). One long statement inside it still makes them wait until it ends. Readers of other tables do not wait at all ([per-table reads](transactions.md#per-table-reads)).
- A `CALL` that changes nothing is not a transaction at all: it locks nothing and runs like a `SELECT`. See [a CALL that only reads](procedures.md#read-only-call).
In this example the first `UPDATE` takes 100 from Ram. Then the ledger row fails, because account 99 does not exist. The whole `CALL` is undone, so Ram still has 1000.
**Command line: A failed CALL saves nothing** (shows an error)
Setup:
```text
CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL DEFAULT 0);
CREATE TABLE ledger (id LONG PRIMARY KEY AUTO, from_account INT NOT NULL REFERENCES accounts(id), to_account INT NOT NULL REFERENCES accounts(id), amount DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
CREATE PROCEDURE transfer(from_id INT, to_id INT, amt DECIMAL(2)) AS BEGIN
UPDATE accounts SET balance = balance - amt WHERE id = from_id;
UPDATE accounts SET balance = balance + amt WHERE id = to_id;
INSERT INTO ledger (from_account, to_account, amount) VALUES (from_id, to_id, amt);
END;
```
```bash
kdb sql market "CALL transfer(1, 99, 100)"
kdb sql market "SELECT id, owner, balance FROM accounts ORDER BY id"
```
Output:
`> kdb sql market "CALL transfer(1, 99, 100)"`
```text
error: procedure 'transfer', line 4: column 'to_account' = '99' has no matching row in accounts(id)
```
exit code 1
`> kdb sql market "SELECT id, owner, balance FROM accounts ORDER BY id"`
```text
id | owner | balance
---+-------+--------
1 | Ram | 1000.00
2 | Sita | 0.00
(2 rows, 0.3 ms)
```
**Example: Two CALLs in one transaction, then ROLLBACK**
```sql
CREATE PROCEDURE deposit(account INT, amt DECIMAL(2)) AS BEGIN
UPDATE accounts SET balance = balance + amt WHERE id = account;
END;
BEGIN;
CALL deposit(2, 100);
CALL deposit(3, 100);
SELECT id, balance FROM accounts ORDER BY id;
ROLLBACK;
SELECT id, balance FROM accounts ORDER BY id;
```
Result:
`CREATE PROCEDURE deposit(account INT, amt DECIMAL(2)) AS BEGIN`
```text
procedure 'deposit' created
```
`BEGIN`
```text
transaction started
```
`CALL deposit(2, 100)`
```text
procedure 'deposit' done: 1 row(s) changed
```
`CALL deposit(3, 100)`
```text
procedure 'deposit' done: 1 row(s) changed
```
`SELECT id, balance FROM accounts ORDER BY id`
| id (INT) | balance (DECIMAL(2)) |
|---|---|
| 1 | 1000.00 |
| 2 | 100.00 |
| 3 | 600.00 |
(3 rows)
`ROLLBACK`
```text
transaction rolled back: 2 change(s) undone
```
`SELECT id, balance FROM accounts ORDER BY id`
| id (INT) | balance (DECIMAL(2)) |
|---|---|
| 1 | 1000.00 |
| 2 | 0.00 |
| 3 | 500.00 |
(3 rows)
## A CALL that only reads
Syntax:
```sql
CREATE PROCEDURE name(...) READ ONLY AS BEGIN ... END -- promised: it never changes data
CALL name(...) -- runs like a SELECT
```
A procedure that only reads (a report, a lookup, a chart query) runs like a `SELECT`:
- it locks no table and starts no transaction;
- it does not wait for writers of the tables it reads, and it does not make readers wait;
- it sees the last committed rows, like a `SELECT`. Changes of an open transaction are not seen.
- a long one does not hold writers up either: when a writer waits, it lets it in between two of its statements. So each statement sees the rows committed when that statement starts.
KDB works this out by itself before each `CALL`, from everything the body runs: the procedures it calls, the views and saved functions it uses.
A `CALL` stays a normal transaction (it locks its tables) when its body, or anything it calls, has one of these:
| In the body | Why |
|---|---|
| `INSERT`, `UPDATE`, `DELETE` | it changes data |
| `SELECT ... FOR UPDATE` | it locks rows for a later change |
| `nextval`, `setval`, `set_config`, `pg_advisory_...`, `pg_sleep` | they change state, or wait on purpose |
| [`EXECUTE`](procedure-language.md#execute) of text made while it runs (`'...' \|\| name`) | KDB cannot tell what it runs |
| `SHOW ...`, `information_schema` or `pg_catalog` | they read the whole database |
| `EXPLAIN CALL` | it works through another procedure's steps |
`EXPLAIN` of anything else only shows a plan, so it does not count as a change.
Write **`READ ONLY`** to make it a promise:
- `CREATE PROCEDURE` refuses a body that changes data, with the reason: `procedure 'x' cannot be READ ONLY: it changes table 'accounts'`.
- If a procedure it calls is later replaced by one that writes, its `CALL` fails before it runs: `procedure 'x' is declared READ ONLY, but it changes table ...`. Nothing is changed.
- **Notes:**
- Inside `BEGIN ... COMMIT`, a read-only `CALL` is part of the transaction and sees its changes, like any statement there.
- `kdb serve --read-only-calls off` (or `KDB_READ_ONLY_CALLS=off`) makes every `CALL` a transaction again, as before. `READ ONLY` is then only checked when the procedure is created.
- A database with a `READ ONLY` procedure cannot be opened by a KDB older than 2026-09-24 ([versions](versions.md)).
**Example: A report procedure that only reads**
```sql
CREATE PROCEDURE rich_accounts(at_least DECIMAL(2)) READ ONLY AS BEGIN
SELECT id, owner, balance FROM accounts WHERE balance >= at_least ORDER BY id;
PRINT concat((SELECT count(*) FROM ledger), ' ledger rows');
END;
CALL rich_accounts(100);
```
Result:
`CREATE PROCEDURE rich_accounts(at_least DECIMAL(2)) READ ONLY AS BEGIN`
```text
procedure 'rich_accounts' created
```
`CALL rich_accounts(100)`
```text
procedure 'rich_accounts' done: 0 row(s) changed
```
`SELECT id, owner, balance FROM accounts WHERE balance >= at_least ORDER BY id`
| id (INT) | owner (STRING) | balance (DECIMAL(2)) |
|---|---|---|
| 1 | Ram | 1000.00 |
| 3 | Hari | 500.00 |
(2 rows)
`print`
| output (STRING) |
|---|
| 0 ledger rows |
(1 row)
**Example: READ ONLY refuses a body that changes data** (shows an error)
```sql
CREATE PROCEDURE empty_accounts() READ ONLY AS BEGIN
UPDATE accounts SET balance = 0;
END;
```
Result:
Error: `procedure 'empty_accounts' cannot be READ ONLY: it changes table 'accounts'. Take READ ONLY away to let it change data`
## Matching another system's rounding
A `DOUBLE` keeps about 16 digits: `9609.0 / 17` is `565.2352941176...` in KDB.
Many programs round a rate before they store it, for example to 6 places: `565.235294`.
When a procedure takes over a job from such a program, round the same way, with [`round(x, 6)`](functions-math.md#round):
| Where | Why |
|---|---|
| every rate or ratio the procedure stores | the stored numbers then equal the old program's, digit for digit |
| both sides of a comparison in a filter | a difference in the 7th place can flip `a > b`, so a row appears or disappears |
- `round` halves go away from zero (`round(0.5)` is `1`), like `math.Round` in Go.
- Compare the two systems' tables to the same number of places. A check that allows a tiny difference does not show that one side stores more digits.
- For money that must add up exactly, use a [`DECIMAL`](column-types.md#decimal) column instead.
**Example: Store the buy rate rounded to 6 places**
Setup:
```sql
CREATE TABLE broker_day (broker INT, amount DOUBLE, quantity LONG);
INSERT INTO broker_day VALUES (1, 9609, 17), (2, 1245.5, 3);
CREATE TABLE broker_rate (broker INT PRIMARY KEY, buy_rate DOUBLE, buy_rate_full DOUBLE);
CREATE PROCEDURE build_rates() AS BEGIN
DELETE FROM broker_rate;
INSERT INTO broker_rate SELECT broker, round(amount / quantity, 6), amount / quantity FROM broker_day;
END;
```
```sql
CALL build_rates();
SELECT broker, buy_rate, buy_rate_full FROM broker_rate ORDER BY broker;
```
Result:
`CALL build_rates()`
```text
procedure 'build_rates' done: 2 row(s) changed
```
`SELECT broker, buy_rate, buy_rate_full FROM broker_rate ORDER BY broker`
| broker (INT) | buy_rate (DOUBLE) | buy_rate_full (DOUBLE) |
|---|---|---|
| 1 | 565.235294 | 565.235294117647 |
| 2 | 415.166667 | 415.166666666667 |
(2 rows)
**Example: Round both sides before a filter compares them**
```sql
SELECT 9609.0 / 17 > 565.235294 AS full_is_bigger,
round(9609.0 / 17, 6) > 565.235294 AS rounded_is_bigger;
```
Result:
| full_is_bigger (BOOL) | rounded_is_bigger (BOOL) |
|---|---|
| true | false |
(1 row)
## CALL ... WITH DEBUG
Syntax:
```sql
CALL name(argument, ...) WITH DEBUG
```
Runs the procedure and adds a result called `debug`, with one row for every step it took.
Use it to find out why a procedure does something you did not expect.
| Column | Meaning |
|---|---|
| `step` | 1, 2, 3, ... in the order the steps started |
| `routine` | the procedure the step is in, or `trigger name` for a trigger. Empty for the `CALL` itself. |
| `statement` | the statement, or the control line: `IF ... THEN`, `ELSIF ... THEN`, `ELSE`, `WHILE ... DO`, `FOR r IN (...) DO`, `FOR r: row 2 of 5` |
| `rows` | rows changed (`INSERT`, `UPDATE`, `DELETE`, `CALL`), rows returned (`SELECT`), rows read (`OPEN`, `FOR`) |
| `time_us` | the time of the step in microseconds (millionths of a second). The first row is the whole `CALL`. |
| `changes` | variables that got a new value (`have=1000.00`), the result of a condition (`true` or `false`), the `PRINT` text, the values of a `FOR` row, or `failed: `. A statement that read a table also says `partitions read: N`; so do `IF`, `WHILE` and `FOR` lines whose query read one, and the `CALL` row counts the whole call. |
- **Returns:** the usual results of the `CALL`, and the `debug` result last.
- **Notes:**
- With `WITH DEBUG`, the `PRINT` lines are in the `debug` result; there is no separate `print` result.
- When the `CALL` fails, the `debug` rows up to the error are still returned, and the last one says `failed`.
- Triggers that run during the `CALL` add their steps too.
- At most 100,000 steps are kept. Long statement text is cut after 200 characters.
- `time_us` is different every time you run it.
- `partitions read` shows whether a time filter skipped partitions: a step that reads all of them where you expected one needs a look. Rows scanned are not counted. To see the plan without running anything, use [EXPLAIN CALL](explain.md#explain-call).
**Example: Every step of a transfer**
```sql
CREATE PROCEDURE transfer(from_id INT, to_id INT, amt DECIMAL(2)) AS BEGIN
DECLARE have DECIMAL(2) DEFAULT (SELECT balance FROM accounts WHERE id = from_id);
IF have < amt THEN
RAISE concat('not enough money in account ', from_id);
ELSIF amt >= 500 THEN
PRINT 'big transfer: check it by hand';
END IF;
UPDATE accounts SET balance = balance - amt WHERE id = from_id;
UPDATE accounts SET balance = balance + amt WHERE id = to_id;
INSERT INTO ledger (from_account, to_account, amount) VALUES (from_id, to_id, amt);
END;
CALL transfer(1, 2, 600) WITH DEBUG;
```
Result:
`CREATE PROCEDURE transfer(from_id INT, to_id INT, amt DECIMAL(2)) AS BEGIN`
```text
procedure 'transfer' created
```
`CALL transfer(1, 2, 600) WITH DEBUG`
```text
procedure 'transfer' done: 3 row(s) changed
```
`debug`
| step (LONG) | routine (STRING) | statement (STRING) | rows (LONG) | time_us (LONG) | changes (STRING) |
|---|---|---|---|---|---|
| 1 | | CALL transfer(1, 2, 600) WITH DEBUG | 3 | 3276 | |
| 2 | transfer | DECLARE have DECIMAL(2) DEFAULT (SELECT balance FROM accounts WHERE id = from_id) | 0 | 123 | have=1000.00 |
| 3 | transfer | IF have < amt THEN | 0 | 5 | false |
| 4 | transfer | ELSIF amt >= 500 THEN | 0 | 2 | true |
| 5 | transfer | PRINT 'big transfer: check it by hand' | 0 | 1 | big transfer: check it by hand |
| 6 | transfer | UPDATE accounts SET balance = balance - amt WHERE id = from_id | 1 | 61 | |
| 7 | transfer | UPDATE accounts SET balance = balance + amt WHERE id = to_id | 1 | 17 | |
| 8 | transfer | INSERT INTO ledger (from_account, to_account, amount) VALUES (from_id, to_id, amt) | 1 | 94 | |
(8 rows)
**Example: The debug rows of a CALL that fails** (shows an error)
```sql
CREATE PROCEDURE transfer(from_id INT, to_id INT, amt DECIMAL(2)) AS BEGIN
DECLARE have DECIMAL(2) DEFAULT (SELECT balance FROM accounts WHERE id = from_id);
IF have < amt THEN
RAISE concat('not enough money in account ', from_id, ': it has ', have);
END IF;
UPDATE accounts SET balance = balance - amt WHERE id = from_id;
UPDATE accounts SET balance = balance + amt WHERE id = to_id;
END;
CALL transfer(2, 1, 50) WITH DEBUG;
```
Result:
`CREATE PROCEDURE transfer(from_id INT, to_id INT, amt DECIMAL(2)) AS BEGIN`
```text
procedure 'transfer' created
```
`debug`
| step (LONG) | routine (STRING) | statement (STRING) | rows (LONG) | time_us (LONG) | changes (STRING) |
|---|---|---|---|---|---|
| 1 | | CALL transfer(2, 1, 50) WITH DEBUG | 0 | 314 | failed |
| 2 | transfer | DECLARE have DECIMAL(2) DEFAULT (SELECT balance FROM accounts WHERE id = from_id) | 0 | 157 | have=0.00 |
| 3 | transfer | IF have < amt THEN | 0 | 10 | true |
| 4 | transfer | RAISE concat('not enough money in account ', from_id, ': it has ', have) | 0 | 97 | failed: not enough money in account 2: it has 0.00 |
(4 rows)
Error: `procedure 'transfer', line 4: not enough money in account 2: it has 0.00`
## DROP PROCEDURE
Syntax:
```sql
DROP PROCEDURE [IF EXISTS] name
```
Removes a procedure.
- **Returns:** `procedure 'name' dropped`. With `IF EXISTS`, a name that does not exist gives the message `procedure 'name' does not exist` and no error.
- **Notes:** KDB does not check who uses the procedure. Other procedures and triggers that `CALL` it fail when they run, with `procedure 'name' does not exist`. `DROP PROCEDURE` cannot be used inside `BEGIN ... COMMIT`.
**Example**
```sql
CREATE PROCEDURE hello() AS BEGIN PRINT 'hello'; END;
DROP PROCEDURE hello;
DROP PROCEDURE IF EXISTS hello;
```
Result:
`CREATE PROCEDURE hello() AS BEGIN`
```text
procedure 'hello' created
```
`DROP PROCEDURE hello`
```text
procedure 'hello' dropped
```
`DROP PROCEDURE IF EXISTS hello`
```text
procedure 'hello' does not exist
```
**Example: A trigger that calls a dropped procedure fails** (shows an error)
```sql
CREATE TABLE posts (id INT PRIMARY KEY, body STRING);
CREATE PROCEDURE recount() AS BEGIN PRINT 'counting'; END;
CREATE TRIGGER count_posts AFTER INSERT ON posts EXECUTE CALL recount();
DROP PROCEDURE recount;
INSERT INTO posts VALUES (1, 'hello');
```
Result:
`CREATE TABLE posts (`
```text
table 'posts' created
```
`CREATE PROCEDURE recount() AS BEGIN`
```text
procedure 'recount' created
```
`CREATE TRIGGER count_posts AFTER INSERT ON posts EXECUTE CALL recount()`
```text
trigger 'count_posts' created on table 'posts'
```
`DROP PROCEDURE recount`
```text
procedure 'recount' dropped
```
Error: `trigger 'count_posts', line 1: procedure 'recount' does not exist (SHOW PROCEDURES lists them)`
## SHOW PROCEDURES
Syntax:
```sql
SHOW PROCEDURES
```
Lists the procedures of the database, in name order.
| Column | Meaning |
|---|---|
| `name` | the procedure name |
| `arguments` | the parameters and their types |
| `definition` | the whole `CREATE PROCEDURE` statement, written again by KDB (one statement per line) |
- **Notes:** the line numbers in error messages (`procedure 'transfer', line 4: ...`) count the lines of this `definition`: `CREATE PROCEDURE ... AS BEGIN` is line 1. The text table of `kdb sql` cuts long text after 48 characters; use `--format csv` or `--format json` to see all of it.
**Example**
```sql
CREATE PROCEDURE deposit(account INT, amt DECIMAL(2)) AS BEGIN
UPDATE accounts SET balance = balance + amt WHERE id = account;
END;
CREATE PROCEDURE hello() AS BEGIN PRINT 'hello'; END;
SHOW PROCEDURES;
```
Result:
`CREATE PROCEDURE deposit(account INT, amt DECIMAL(2)) AS BEGIN`
```text
procedure 'deposit' created
```
`CREATE PROCEDURE hello() AS BEGIN`
```text
procedure 'hello' created
```
`SHOW PROCEDURES`
| name (STRING) | arguments (STRING) | definition (STRING) |
|---|---|---|
| deposit | account INT, amt DECIMAL(2) | CREATE PROCEDURE deposit(account INT, amt DECIMAL(2)) AS BEGIN UPDATE accounts SET balance = balance + amt WHERE id = account; END |
| hello | | CREATE PROCEDURE hello() AS BEGIN PRINT 'hello'; END |
(2 rows)