KDB knowledge

Transactions

BEGIN, COMMIT and ROLLBACK: save many changes together, or none of them.

A transaction is a group of changes that KDB saves together, or not at all. Nobody ever sees half of it.

Moving money is the classic case. A transfer is three changes: take money from one account, give it to the other, and write a ledger row (a row in the list of all transfers). If only the first change is saved, money is lost. Put them in a transaction.

Example: Accounting: a transfer between two accounts, then check the books

Setup:

CREATE TABLE accounts (
  id INT PRIMARY KEY,
  owner STRING NOT NULL,
  opening DECIMAL(2) NOT NULL,
  balance DECIMAL(2) NOT NULL
);
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,
  note STRING,
  created TIMESTAMP DEFAULT now()
);
INSERT INTO accounts VALUES (1, 'Ram', 1000, 1000), (2, 'Sita', 0, 0), (3, 'Hari', 500, 500);
BEGIN;
UPDATE accounts SET balance = balance - 250.50 WHERE id = 1;
UPDATE accounts SET balance = balance + 250.50 WHERE id = 2;
INSERT INTO ledger (from_account, to_account, amount, note) VALUES (1, 2, 250.50, 'rent');
COMMIT;

-- Check the books: the total never changes, and every balance matches the ledger.
SELECT sum(opening) AS money_at_start, sum(balance) AS money_now FROM accounts;
SELECT a.id, a.owner, a.balance,
       a.opening + coalesce(i.total_in, 0) - coalesce(o.total_out, 0) AS from_ledger,
       a.balance = a.opening + coalesce(i.total_in, 0) - coalesce(o.total_out, 0) AS ok
FROM accounts a
LEFT JOIN (SELECT to_account, sum(amount) AS total_in FROM ledger GROUP BY to_account) i ON i.to_account = a.id
LEFT JOIN (SELECT from_account, sum(amount) AS total_out FROM ledger GROUP BY from_account) o ON o.from_account = a.id
ORDER BY a.id;

Result:

BEGIN

transaction started

UPDATE accounts SET balance = balance - 250.50 WHERE id = 1

1 row(s) updated

UPDATE accounts SET balance = balance + 250.50 WHERE id = 2

1 row(s) updated

INSERT INTO ledger (from_account, to_account, amount, note) VALUES (1, 2, 250.50, 'rent')

1 row(s) inserted

COMMIT

transaction committed: 3 change(s) saved

SELECT sum(opening) AS money_at_start, sum(balance) AS money_now FROM accounts

money_at_start (DECIMAL(2))money_now (DECIMAL(2))
1500.001500.00

(1 row)

SELECT a.id, a.owner, a.balance, a.opening + coalesce(i.total_in, 0) - coalesce(o.total_out, 0) AS f

id (INT)owner (STRING)balance (DECIMAL(2))from_ledger (DECIMAL(2))ok (BOOL)
1Ram749.50749.50true
2Sita250.50250.50true
3Hari500.00500.00true

(3 rows)

BEGIN

Syntax:

BEGIN
BEGIN TRANSACTION
BEGIN WORK

Starts a transaction. The statements after it are saved only at COMMIT. BEGIN, BEGIN TRANSACTION and BEGIN WORK are the same.

Example: The SELECT inside sees the change; after ROLLBACK it is gone (sample data: sample)

BEGIN TRANSACTION;
UPDATE securities SET sector = 'Banks' WHERE symbol = 'CITY';
SELECT symbol, sector FROM securities WHERE symbol = 'CITY';
ROLLBACK;
SELECT symbol, sector FROM securities WHERE symbol = 'CITY';

Result:

BEGIN

transaction started

UPDATE securities SET sector = 'Banks' WHERE symbol = 'CITY'

1 row(s) updated

SELECT symbol, sector FROM securities WHERE symbol = 'CITY'

symbol (SYMBOL)sector (STRING)
CITYBanks

(1 row)

ROLLBACK

transaction rolled back: 1 change(s) undone

SELECT symbol, sector FROM securities WHERE symbol = 'CITY'

symbol (SYMBOL)sector (STRING)
CITYBanking

(1 row)

Example: BEGIN twice (shows an error)

BEGIN;
BEGIN;

Result:

transaction started

Error: a transaction is already open; COMMIT or ROLLBACK it first (the transaction was rolled back)

COMMIT

Syntax:

COMMIT
COMMIT TRANSACTION
COMMIT WORK

Saves every change of the open transaction, with one write to the log, and ends the transaction.

Example: App sign-up: the user and the first audit row, together

Setup:

CREATE TABLE app_users (id INT PRIMARY KEY, email STRING NOT NULL, created TIMESTAMP DEFAULT now());
CREATE TABLE audit_log (id LONG PRIMARY KEY AUTO, action STRING, detail STRING);
BEGIN WORK;
INSERT INTO app_users (id, email) VALUES (1, 'ram@example.com');
INSERT INTO audit_log (action, detail) VALUES ('sign-up', 'user 1: ram@example.com');
COMMIT WORK;
SELECT u.id, u.email, a.action FROM app_users u JOIN audit_log a ON a.detail = concat('user ', u.id, ': ', u.email);

Result:

BEGIN

transaction started

INSERT INTO app_users (id, email) VALUES (1, 'ram@example.com')

1 row(s) inserted

INSERT INTO audit_log (action, detail) VALUES ('sign-up', 'user 1: ram@example.com')

1 row(s) inserted

COMMIT

transaction committed: 2 change(s) saved

SELECT u.id, u.email, a.action FROM app_users AS u JOIN audit_log AS a ON a.detail = concat('user ',

id (INT)email (STRING)action (STRING)
1ram@example.comsign-up

(1 row)

Example: COMMIT without BEGIN (shows an error)

COMMIT;

Result:

Error: COMMIT without BEGIN: no transaction is open

ROLLBACK

Syntax:

ROLLBACK
ROLLBACK TRANSACTION
ROLLBACK WORK

Undoes every change of the open transaction and ends it. The data is exactly as it was before BEGIN.

Example: Forum: undo a post and its counter

Setup:

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, body STRING);
INSERT INTO topics (id) VALUES (1);
BEGIN;
INSERT INTO posts VALUES (1, 1, 'Buy CITY now!!!');
UPDATE topics SET posts = posts + 1 WHERE id = 1;
SELECT id, posts FROM topics;
ROLLBACK WORK;
SELECT id, posts FROM topics;
SELECT count(*) AS posts FROM posts;

Result:

BEGIN

transaction started

INSERT INTO posts VALUES (1, 1, 'Buy CITY now!!!')

1 row(s) inserted

UPDATE topics SET posts = posts + 1 WHERE id = 1

1 row(s) updated

SELECT id, posts FROM topics

id (INT)posts (LONG)
11

(1 row)

ROLLBACK

transaction rolled back: 2 change(s) undone

SELECT id, posts FROM topics

id (INT)posts (LONG)
10

(1 row)

SELECT count(*) AS posts FROM posts

posts (LONG)
0

(1 row)

Example: ROLLBACK without BEGIN (shows an error)

ROLLBACK;

Result:

Error: ROLLBACK without BEGIN: no transaction is open

SAVEPOINT

Syntax:

SAVEPOINT name
ROLLBACK [WORK | TRANSACTION] TO [SAVEPOINT] name
RELEASE [SAVEPOINT] name

A savepoint is a mark inside BEGIN ... COMMIT. ROLLBACK TO undoes only the work after the mark; the work before it stays, and the transaction goes on.

StatementWhat it does
SAVEPOINT namemarks the point. A name used again marks a new point: the newest one counts.
ROLLBACK TO SAVEPOINT nameundoes every change after the mark. The mark stays, so you can go back to it again. Later marks are gone.
RELEASE SAVEPOINT nameforgets the mark and every later one. The changes stay in the transaction.

Example: Keep the order, undo the discount

Setup:

CREATE TABLE orders (id INT PRIMARY KEY, symbol SYMBOL, qty LONG, price DECIMAL(2));
BEGIN;
INSERT INTO orders VALUES (1, 'CITY', 10, 510.00);
SAVEPOINT before_discount;
UPDATE orders SET price = price * 0.5 WHERE id = 1;
ROLLBACK TO SAVEPOINT before_discount;
INSERT INTO orders VALUES (2, 'COAST', 5, 385.00);
COMMIT;
SELECT * FROM orders ORDER BY id;

Result:

BEGIN

transaction started

INSERT INTO orders VALUES (1, 'CITY', 10, 510.00)

1 row(s) inserted

SAVEPOINT before_discount

savepoint before_discount set

UPDATE orders SET price = price * 0.5 WHERE id = 1

1 row(s) updated

ROLLBACK TO SAVEPOINT before_discount

rolled back to savepoint before_discount: 1 row change(s) undone

INSERT INTO orders VALUES (2, 'COAST', 5, 385.00)

1 row(s) inserted

COMMIT

transaction committed: 2 change(s) saved

SELECT * FROM orders ORDER BY id

id (INT)symbol (SYMBOL)qty (LONG)price (DECIMAL(2))
1CITY10510.00
2COAST5385.00

(2 rows)

After an error: ROLLBACK TO SAVEPOINT

In a session (an HTTP session, kdb shell, the PostgreSQL protocol), an error after a savepoint does not throw the transaction away. As in PostgreSQL:

  1. The work after the newest savepoint is undone, and the transaction is aborted: every statement is refused with current transaction is aborted ... (25P02), except these.
  2. ROLLBACK TO SAVEPOINT name goes back to that mark and makes the transaction usable again.
  3. ROLLBACK ends it. COMMIT saves nothing and says so.

Without a savepoint, an error still rolls back the whole transaction (when a statement fails).

HTTP: A duplicate key after a savepoint: go back and go on

Setup:

CREATE TABLE users (id INT PRIMARY KEY, email STRING UNIQUE);
INSERT INTO users VALUES (1, 'ram@example.np');
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; INSERT INTO users VALUES (2, 'sita@example.np'); SAVEPOINT before_hari
###
POST /exec
X-KDB-Session: {{session}}

INSERT INTO users VALUES (3, 'ram@example.np')
###
POST /exec
X-KDB-Session: {{session}}

ROLLBACK TO SAVEPOINT before_hari; INSERT INTO users VALUES (3, 'hari@example.np'); COMMIT
###
POST /exec

SELECT id, email FROM users ORDER BY id

Response:

POST /session

HTTP 200 (application/json)

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

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; INSERT INTO users VALUES (2, 'sita@example.np'); SAVEPOINT before_hari",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 13, "execute_us": 5}
    },
    {
      "statement": "INSERT INTO users VALUES (2, 'sita@example.np')",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) inserted",
      "timings": {"parse_us": 13, "execute_us": 48}
    },
    {
      "statement": "SAVEPOINT before_hari",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "savepoint before_hari set",
      "timings": {"parse_us": 13, "execute_us": 6}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 39, "execute_us": 59}
}

POST /exec

HTTP 400 (application/json)

{
  "query": "INSERT INTO users VALUES (3, 'ram@example.np')",
  "results": [],
  "saved": 0,
  "rolled_back": true,
  "error": "column 'email' must be unique: 'ram@example.np' is already used (the transaction is aborted; send ROLLBACK TO SAVEPOINT before_hari to go on from the savepoint, or ROLLBACK to end it)",
  "sqlstate": "23505",
  "table": "users",
  "constraint": "users_email_key",
  "column_name": "email"
}

POST /exec

HTTP 200 (application/json)

{
  "query": "ROLLBACK TO SAVEPOINT before_hari; INSERT INTO users VALUES (3, 'hari@example.np'); COMMIT",
  "results": [
    {
      "statement": "ROLLBACK TO SAVEPOINT before_hari",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "rolled back to savepoint before_hari: 0 row change(s) undone",
      "timings": {"parse_us": 6, "execute_us": 6}
    },
    {
      "statement": "INSERT INTO users VALUES (3, 'hari@example.np')",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) inserted",
      "timings": {"parse_us": 6, "execute_us": 31}
    },
    {
      "statement": "COMMIT",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction committed: 2 change(s) saved",
      "timings": {"parse_us": 6, "execute_us": 8}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 18, "execute_us": 45}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "SELECT id, email FROM users ORDER BY id",
  "results": [
    {
      "statement": "SELECT id, email FROM users ORDER BY id",
      "columns": [
        {"name": "id", "type": "INT"},
        {"name": "email", "type": "STRING"}
      ],
      "dataset": [
        [1, "ram@example.np"],
        [2, "sita@example.np"],
        [3, "hari@example.np"]
      ],
      "count": 3,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 38, "execute_us": 100}
    }
  ],
  "columns": [
    {"name": "id", "type": "INT"},
    {"name": "email", "type": "STRING"}
  ],
  "dataset": [
    [1, "ram@example.np"],
    [2, "sita@example.np"],
    [3, "hari@example.np"]
  ],
  "count": 3,
  "timings": {"parse_us": 38, "execute_us": 100}
}

What is one transaction by itself

Syntax:

INSERT ... / UPDATE ... / DELETE ...    -- one statement: all its rows, or none
CALL procedure(...)                     -- the whole procedure
a statement that fires triggers         -- the statement and all its triggers

You do not always need BEGIN. These are each one transaction by themselves:

WhatAll or nothing
one INSERT, UPDATE or DELETEall its rows are written, or none (an INSERT of 1,000 rows with one bad row writes nothing)
one CALLevery change of the procedure, and of the procedures it calls (see procedures)
a statement that fires triggersthe statement and everything its triggers change

Command line: One INSERT with a bad row writes no row (shows an error)

Setup:

CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL);
INSERT INTO brokers VALUES (1, 'Keystone Securities');
kdb sql market "INSERT INTO brokers VALUES (34, 'Vision Securities'), (45, 'Imperial Securities'), (1, 'Keystone again')"
kdb sql market "SELECT id, name FROM brokers ORDER BY id"

Output:

> kdb sql market "INSERT INTO brokers VALUES (34, 'Vision Securities'), (45, 'Imperial Securities'), (1, 'Keystone again')"

error: row 3: a row with id='1' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it

exit code 1

> kdb sql market "SELECT id, name FROM brokers ORDER BY id"

id | name               
---+--------------------
 1 | Keystone Securities
(1 row, 0.3 ms)

Command line: Without BEGIN, the statements before the error stay saved (shows an error)

Setup:

CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL);
INSERT INTO brokers VALUES (1, 'Keystone Securities');
kdb sql market "INSERT INTO brokers VALUES (34, 'Vision Securities'); INSERT INTO brokers VALUES (1, 'Keystone again'); INSERT INTO brokers VALUES (45, 'Imperial Securities')"
kdb sql market "SELECT id, name FROM brokers ORDER BY id"

Output:

> kdb sql market "INSERT INTO brokers VALUES (34, 'Vision Securities'); INSERT INTO brokers VALUES (1, 'Keystone again'); INSERT INTO brokers VALUES (45, 'Imperial Securities')"

1 row(s) inserted
error: a row with id='1' already exists in 'brokers'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it

exit code 1

> kdb sql market "SELECT id, name FROM brokers ORDER BY id"

id | name               
---+--------------------
 1 | Keystone Securities
34 | Vision Securities  
(2 rows, 0.3 ms)

When a statement fails

Syntax:

BEGIN; statement; failing statement;   -- the whole transaction is rolled back here

When a statement inside BEGIN ... COMMIT fails (a duplicate key, REFERENCES, a CHECK, RAISE in a procedure or trigger, a typing error in a column name, ...), KDB rolls back the whole transaction at once.

Example: A transfer to an account that does not exist (shows an error)

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
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);
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 99;
INSERT INTO ledger (from_account, to_account, amount) VALUES (1, 99, 100);
COMMIT;

Result:

BEGIN

transaction started

UPDATE accounts SET balance = balance - 100 WHERE id = 1

1 row(s) updated

UPDATE accounts SET balance = balance + 100 WHERE id = 99

0 row(s) updated

Error: column 'to_account' = '99' has no matching row in accounts(id) (the transaction was rolled back)

Command line: Ram still has 1000.00 after that (shows an error)

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
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);
kdb sql market "BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; INSERT INTO ledger (from_account, to_account, amount) VALUES (1, 99, 100); COMMIT"
kdb sql market "SELECT id, owner, balance FROM accounts ORDER BY id"

Output:

> kdb sql market "BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; INSERT INTO ledger (from_account, to_account, amount) VALUES (1, 99, 100); COMMIT"

transaction started

1 row(s) updated
error: column 'to_account' = '99' has no matching row in accounts(id) (the transaction was rolled back)

exit code 1

> kdb sql market "SELECT id, owner, balance FROM accounts ORDER BY id"

id | owner | balance
---+-------+--------
 1 | Ram   | 1000.00
 2 | Sita  |    0.00
(2 rows, 0.4 ms)

A text that ends without COMMIT

Syntax:

BEGIN; statement; ...   <end of the text>   -- rolled back

kdb sql, POST /exec without a session, and the web console run one text at a time. A transaction cannot stay open after its text ends: when the text ends without COMMIT or ROLLBACK, KDB rolls it back and returns an error.

Example: Forgot the COMMIT (shows an error)

Setup:

CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL);
BEGIN;
INSERT INTO brokers VALUES (58, 'Northgate Securities');
SELECT count(*) AS brokers FROM brokers;

Result:

BEGIN

transaction started

INSERT INTO brokers VALUES (58, 'Northgate Securities')

1 row(s) inserted

SELECT count(*) AS brokers FROM brokers

brokers (LONG)
1

(1 row)

Error: BEGIN without COMMIT: the transaction was rolled back (end it with COMMIT to save the changes)

Statements not allowed inside a transaction

Syntax:

CREATE ...  DROP ...  ALTER ...  RENAME ...  BACKUP DATABASE   -- before BEGIN or after COMMIT

Changes of the database structure cannot be undone like rows, so they are refused inside BEGIN ... COMMIT: CREATE / DROP / ALTER of tables, views, indexes, functions, procedures and triggers, RENAME TABLE, and BACKUP DATABASE. Run them before BEGIN or after COMMIT.

Example: CREATE TABLE inside a transaction (shows an error)

BEGIN;
CREATE TABLE archive (id INT PRIMARY KEY);

Result:

transaction started

Error: CREATE TABLE cannot be used inside a transaction; COMMIT or ROLLBACK first (the transaction was rolled back)

Example: CREATE PROCEDURE inside a transaction (shows an error)

BEGIN;
CREATE PROCEDURE hello() AS BEGIN PRINT 'hello'; END;

Result:

transaction started

Error: CREATE PROCEDURE cannot be used inside a transaction; COMMIT or ROLLBACK first (the transaction was rolled back)

SELECT ... FOR UPDATE

Syntax:

SELECT columns FROM table WHERE ... FOR UPDATE

A SELECT that says: "I read these rows because I will change them". Write it at the start of a transaction, before the UPDATEs.

Example: Read the balances to be changed, then move the money

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
BEGIN;
SELECT id, balance FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 250.50 WHERE id = 1;
UPDATE accounts SET balance = balance + 250.50 WHERE id = 2;
COMMIT;
SELECT id, owner, balance FROM accounts ORDER BY id;

Result:

BEGIN

transaction started

SELECT id, balance FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE

id (INT)balance (DECIMAL(2))
11000.00
20.00

(2 rows)

UPDATE accounts SET balance = balance - 250.50 WHERE id = 1

1 row(s) updated

UPDATE accounts SET balance = balance + 250.50 WHERE id = 2

1 row(s) updated

COMMIT

transaction committed: 2 change(s) saved

SELECT id, owner, balance FROM accounts ORDER BY id

id (INT)owner (STRING)balance (DECIMAL(2))
1Ram749.50
2Sita250.50

(2 rows)

HTTP: SKIP LOCKED: another session holds job 1, so this worker gets job 2

Setup:

CREATE TABLE jobs (id INT PRIMARY KEY, state STRING);
INSERT INTO jobs VALUES (1, 'new'), (2, 'new'), (3, 'new');
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; SELECT id FROM jobs WHERE state = 'new' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED
###
POST /exec

SELECT id, state FROM jobs WHERE state = 'new' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED

Response:

POST /session

HTTP 200 (application/json)

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

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; SELECT id FROM jobs WHERE state = 'new' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 23, "execute_us": 6}
    },
    {
      "statement": "SELECT id FROM jobs WHERE state = 'new' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED",
      "columns": [
        {"name": "id", "type": "INT"}
      ],
      "dataset": [
        [1]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 23, "execute_us": 151}
    }
  ],
  "columns": [
    {"name": "id", "type": "INT"}
  ],
  "dataset": [
    [1]
  ],
  "count": 1,
  "timings": {"parse_us": 46, "execute_us": 157}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "SELECT id, state FROM jobs WHERE state = 'new' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED",
  "results": [
    {
      "statement": "SELECT id, state FROM jobs WHERE state = 'new' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED",
      "columns": [
        {"name": "id", "type": "INT"},
        {"name": "state", "type": "STRING"}
      ],
      "dataset": [
        [2, "new"]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 66, "execute_us": 84}
    }
  ],
  "columns": [
    {"name": "id", "type": "INT"},
    {"name": "state", "type": "STRING"}
  ],
  "dataset": [
    [2, "new"]
  ],
  "count": 1,
  "timings": {"parse_us": 66, "execute_us": 84}
}

HTTP: NOWAIT: a locked row is an error at once

Setup:

CREATE TABLE jobs (id INT PRIMARY KEY, state STRING);
INSERT INTO jobs VALUES (1, 'new'), (2, 'new');
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; SELECT id FROM jobs WHERE id = 1 FOR UPDATE
###
POST /exec

SELECT id FROM jobs WHERE id = 1 FOR UPDATE NOWAIT

Response:

POST /session

HTTP 200 (application/json)

{"session": "91ede6e2c499432dfd6ae7ad40f6f98d", "idle_timeout_ms": 600000}

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; SELECT id FROM jobs WHERE id = 1 FOR UPDATE",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 16, "execute_us": 4}
    },
    {
      "statement": "SELECT id FROM jobs WHERE id = 1 FOR UPDATE",
      "columns": [
        {"name": "id", "type": "INT"}
      ],
      "dataset": [
        [1]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 16, "execute_us": 64}
    }
  ],
  "columns": [
    {"name": "id", "type": "INT"}
  ],
  "dataset": [
    [1]
  ],
  "count": 1,
  "timings": {"parse_us": 32, "execute_us": 68}
}

POST /exec

HTTP 503 (application/json)

{
  "query": "SELECT id FROM jobs WHERE id = 1 FOR UPDATE NOWAIT",
  "results": [],
  "saved": 0,
  "rolled_back": false,
  "error": "could not obtain lock on row in relation \"jobs\": another transaction holds it (SELECT ... FOR UPDATE), and FOR UPDATE NOWAIT does not wait",
  "sqlstate": "55P03"
}

HTTP: NOWAIT on a row nobody locked: the row

Setup:

CREATE TABLE jobs (id INT PRIMARY KEY, state STRING);
INSERT INTO jobs VALUES (1, 'new'), (2, 'new');
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; SELECT id FROM jobs WHERE id = 1 FOR UPDATE
###
POST /exec

SELECT id FROM jobs WHERE id = 2 FOR UPDATE NOWAIT

Response:

POST /session

HTTP 200 (application/json)

{"session": "12fda51f843e6372cb7f667626ffcc65", "idle_timeout_ms": 600000}

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; SELECT id FROM jobs WHERE id = 1 FOR UPDATE",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 21, "execute_us": 4}
    },
    {
      "statement": "SELECT id FROM jobs WHERE id = 1 FOR UPDATE",
      "columns": [
        {"name": "id", "type": "INT"}
      ],
      "dataset": [
        [1]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 21, "execute_us": 71}
    }
  ],
  "columns": [
    {"name": "id", "type": "INT"}
  ],
  "dataset": [
    [1]
  ],
  "count": 1,
  "timings": {"parse_us": 42, "execute_us": 75}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "SELECT id FROM jobs WHERE id = 2 FOR UPDATE NOWAIT",
  "results": [
    {
      "statement": "SELECT id FROM jobs WHERE id = 2 FOR UPDATE NOWAIT",
      "columns": [
        {"name": "id", "type": "INT"}
      ],
      "dataset": [
        [2]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 25, "execute_us": 61}
    }
  ],
  "columns": [
    {"name": "id", "type": "INT"}
  ],
  "dataset": [
    [2]
  ],
  "count": 1,
  "timings": {"parse_us": 25, "execute_us": 61}
}

Transactions in kdb shell

Syntax:

kdb shell database
kdb> BEGIN;
kdb> ...;
kdb> COMMIT;

In kdb shell, one transaction can go over many lines: the shell keeps one session for the whole time.

Command line: A transfer typed line by line

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
kdb shell market
> BEGIN;
> UPDATE accounts SET balance = balance - 250.50 WHERE id = 1;
> UPDATE accounts SET balance = balance + 250.50 WHERE id = 2;
> SELECT id, owner, balance FROM accounts ORDER BY id;
> COMMIT;
> .exit

Output:

KDB 0.2.0-dev shell on market
End statements with ';'. Type .help for commands, .exit to quit.
kdb> transaction started
kdb> 1 row(s) updated
kdb> 1 row(s) updated
kdb> id | owner | balance
---+-------+--------
 1 | Ram   |  749.50
 2 | Sita  |  250.50
(2 rows, 0.2 ms)
kdb> transaction committed: 2 change(s) saved
kdb>

In the next example, the INSERT fails (id 1 is already used). The shell then refuses the next UPDATE, so it cannot take 10 from Ram outside the transaction. ROLLBACK ends the failed transaction. The shell writes error lines to a separate output (stderr), so on this page they are shown at the end. In a real window, each error comes right after its line.

Command line: After an error, only ROLLBACK is accepted

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
kdb shell market
> BEGIN;
> UPDATE accounts SET balance = balance + 10 WHERE id = 2;
> INSERT INTO accounts VALUES (1, 'Ram again', 0);
> UPDATE accounts SET balance = balance - 10 WHERE id = 1;
> ROLLBACK;
> SELECT id, owner, balance FROM accounts ORDER BY id;
> .exit

Output:

KDB 0.2.0-dev shell on market
End statements with ';'. Type .help for commands, .exit to quit.
kdb> transaction started
kdb> 1 row(s) updated
kdb> kdb> kdb> transaction rolled back (it was already rolled back: a statement in it failed)
kdb> id | owner | balance
---+-------+--------
 1 | Ram   | 1000.00
 2 | Sita  |    0.00
(2 rows, 0.2 ms)
kdb> 
error: a row with id='1' already exists in 'accounts'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it (the transaction was rolled back; send ROLLBACK to end it)
error: the transaction of this session was rolled back (a statement in it failed); nothing of it was saved. Send ROLLBACK to end it, then start again

Transactions over HTTP

Syntax:

POST /session                               -> {"session": "<id>", ...}
POST /exec   X-KDB-Session: <id>   BEGIN; ...
POST /exec   X-KDB-Session: <id>   ...; COMMIT
DELETE /session/<id>

POST /exec without a session runs one text: a transaction must end in the same request. To keep a transaction open over many requests, open a session with POST /session, and send its id in the header X-KDB-Session with every /exec. See HTTP sessions and cursors.

HTTP: A transfer over two requests; another request does not see it until COMMIT

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1
###
POST /exec

SELECT id, balance FROM accounts ORDER BY id
###
POST /exec
X-KDB-Session: {{session}}

UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT
###
POST /exec

SELECT id, balance FROM accounts ORDER BY id
###
DELETE /session/{{session}}

Response:

POST /session

HTTP 200 (application/json)

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

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 24, "execute_us": 9}
    },
    {
      "statement": "UPDATE accounts SET balance = balance - 100 WHERE id = 1",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) updated",
      "timings": {"parse_us": 24, "execute_us": 74}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 48, "execute_us": 83}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "SELECT id, balance FROM accounts ORDER BY id",
  "results": [
    {
      "statement": "SELECT id, balance FROM accounts ORDER BY id",
      "columns": [
        {"name": "id", "type": "INT"},
        {"name": "balance", "type": "DECIMAL(2)"}
      ],
      "dataset": [
        [1, "1000.00"],
        [2, "0.00"]
      ],
      "count": 2,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 27, "execute_us": 63}
    }
  ],
  "columns": [
    {"name": "id", "type": "INT"},
    {"name": "balance", "type": "DECIMAL(2)"}
  ],
  "dataset": [
    [1, "1000.00"],
    [2, "0.00"]
  ],
  "count": 2,
  "timings": {"parse_us": 27, "execute_us": 63}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT",
  "results": [
    {
      "statement": "UPDATE accounts SET balance = balance + 100 WHERE id = 2",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) updated",
      "timings": {"parse_us": 18, "execute_us": 56}
    },
    {
      "statement": "COMMIT",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction committed: 2 change(s) saved",
      "timings": {"parse_us": 18, "execute_us": 13}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 36, "execute_us": 69}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "SELECT id, balance FROM accounts ORDER BY id",
  "results": [
    {
      "statement": "SELECT id, balance FROM accounts ORDER BY id",
      "columns": [
        {"name": "id", "type": "INT"},
        {"name": "balance", "type": "DECIMAL(2)"}
      ],
      "dataset": [
        [1, "900.00"],
        [2, "100.00"]
      ],
      "count": 2,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 2, "execute_us": 91}
    }
  ],
  "columns": [
    {"name": "id", "type": "INT"},
    {"name": "balance", "type": "DECIMAL(2)"}
  ],
  "dataset": [
    [1, "900.00"],
    [2, "100.00"]
  ],
  "count": 2,
  "timings": {"parse_us": 2, "execute_us": 91}
}

DELETE /session/{{session}}

HTTP 200 (application/json)

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

One writer per table

Syntax:

session A: BEGIN; UPDATE accounts SET ... WHERE id = 1;  -- A locks row 1 of accounts (row-level locks)
session B: INSERT INTO ticks ...;                         -- B goes at once: another table
session C: UPDATE accounts SET ... WHERE id = 2;          -- C goes at once: another row
session C: UPDATE accounts SET ... WHERE id = 1;          -- C waits for A's row (5 s unless set), then "a row of table 'accounts' is locked"
session C: SELECT ... FROM accounts;                      -- C does not wait for A's open transaction: it reads the data without A's changes
session A: COMMIT;                                        -- row 1 is free; the next writer goes

A reader waits only while a writer's statement runs (see reader yield for long triggers and CALLs). Between A's requests nothing runs, so C reads at once.

A writer locks every table it uses until its statement ends. Inside BEGIN ... COMMIT it keeps them until the transaction ends - except where it only updated rows by their key: there it holds just those rows (row-level locks, in the table below). Writers of different tables run at the same time. Writers of the same table take turns, so two transfers can never mix, and every transaction sees the data exactly as the one before it left it (this is called serializable).

RuleDetail
Other tablesWriters of different tables do not wait for each other's locks: a live tick feed and a big load into another table run side by side. They can still wait a little for work the whole database shares: a checkpoint that writes the changes to disk, and the disk sync of a commit.
The same tableA second writer waits until the first one ends, first come, first served. Line-protocol writes and bulk loads wait the same way.
What is lockedThe tables a statement changes and reads, and what it sets off: tables its triggers and procedures use, tables linked to it by foreign keys, and _audit_log for audited tables. A procedure or trigger whose EXECUTE text is made while it runs (format(...), '...' || name), and a read of information_schema or pg_catalog in a writer, lock every table. EXECUTE of text written out in the body locks only the tables of that text.
Row-level locksInside BEGIN ... COMMIT, an UPDATE of rows of a table with a primary key locks those rows, not the table, until the transaction ends. Between its statements the transaction gives the table back: another transaction may change other rows of it at once, and one that meets a locked row waits for it, as in PostgreSQL (after the lock timeout: a row of table 'x' is locked, 55P03, or 40001 inside its own transaction). Two transactions that wait for each other's rows: one gets deadlock: ... at once. A ROLLBACK puts back only its own rows. What still keeps the whole table until COMMIT: an INSERT or DELETE in it, an UPDATE that changes a row's key, and any change of a table with another UNIQUE column or unique index, a foreign key (either way) or a trigger - rows the transaction holds could clash with another writer's there. A read in another transaction does not hold up such a transaction. Turn it off with KDB_NO_ROW_LOCKS=1 (every table a transaction writes stays locked until it ends, as before).
SELECT ... FOR UPDATEOn a table with a primary key it locks only the rows it returns, not the table, until the transaction ends: other writers of the table go on, and only a writer of one of those rows waits (row locks).
Several writers at oncekdb serve --table-writers many: requests that touch a table only by single rows and their primary key run on it at the same time, waiting only for the same row (several writers of one table). Off by default.
Several tablesThe tables of one request are locked all at once, always in the same order, so two such requests never wait for each other.
DeadlockA transaction that needs one more table in a later request can meet another transaction that waits for one of its tables. KDB sees this at once: the one that would wait gets deadlock: ... (HTTP status 503) and is rolled back. Run it again.
BusyA writer that waited the lock timeout (5 seconds unless set) gets table 'x' is busy: ... (HTTP status 503, PostgreSQL 55P03; 40001 inside its own BEGIN ... COMMIT, see isolation). Nothing of its statement was done: try the whole transaction again a moment later.
A taken keyAn INSERT ... VALUES that waits for a busy table looks its keys up in the committed rows while it waits. A key that is already there gives the duplicate key error at once (PostgreSQL 23505, HTTP status 400, with the table and constraint named), not 55P03 after the lock timeout. Inside BEGIN the transaction ends, as after any 23505. A key that only the other transaction has added, deleted or changed is not decided yet: the INSERT waits for it. When that transaction commits, the INSERT gets 23505 (if the key is now taken) or goes in; when it rolls back, the row goes in. A key it deleted is never reported as taken. This covers the primary key, UNIQUE columns, plain unique indexes and a time-series DEDUP KEY that includes the time column, with literal values or $1 parameters (not values worked out by an expression, ON CONFLICT, or a table with an INSERT trigger: those wait as before).
Schema changes of a tableCREATE TABLE, ALTER TABLE (RENAME too), DROP TABLE, CREATE INDEX, DROP INDEX, CREATE, ALTER and DROP TRIGGER and TRUNCATE lock only their table (and the tables its foreign keys point at). CREATE TABLE ... AS locks the tables its query reads. CREATE, REFRESH and DROP MATERIALIZED VIEW lock the view and its table. They wait for writers of that table, like any writer (table 'x' is busy: ...). CREATE TABLE (also IF NOT EXISTS and AS SELECT), ALTER TABLE (not SET TTL, SET (dedup_insert ...) or DEDUP), RENAME TABLE and CREATE / DROP INDEX do not wait for writers of other tables at all: they run beside them, also beside one long INSERT ... SELECT, COPY or CALL that never pauses. Only a writer that named the new table before it existed is waited for. The other schema changes of a table (DROP TABLE, triggers, TRUNCATE, materialized views) wait for a long writer of other tables only until it pauses between two of its statements (reader yield). Only the wait for writers counts against the lock timeout: a long SELECT or read-only CALL is waited for as long as it runs, then the schema change runs. Readers wait while the schema change itself runs, which is short. Turn it off with kdb serve --ddl-beside-writers off (they wait for the pause again) or --scoped-ddl off (every schema change locks every table).
Views, procedures, functionschange a definition, not a table: they lock no table, and writers of tables go on (creating or altering a CACHED view and REFRESH VIEW lock the tables its query reads). They still wait for a writer that may use the old definition next: a view change for a running writer that reads a view, a procedure or function change for a running writer that runs a procedure, a function or a plpgsql trigger (a long CALL, say). After the lock timeout that is database is busy: ....
Other schema changesSequences, BACKUP, DEDUP TABLE, a change of a table a materialized view reads, a CREATE TABLE ... AS whose query reads information_schema, and a schema change sent in one request with statements it affects (CREATE OR REPLACE PROCEDURE p ...; CALL p(), or CREATE VIEW v ...; INSERT ... SELECT FROM v) lock every table: they wait until no transaction is open, and after the lock timeout get database is busy: .... While such a change waits for a writer that was already running, writers of other tables still go ahead of it. Once that writer ends, new writers wait behind the change, so it is never kept out.
Schema statements with nothing to dolock nothing and answer at once: CREATE TABLE IF NOT EXISTS of a table that is there, ALTER TABLE ... ADD COLUMN IF NOT EXISTS of a column that is there, DROP COLUMN IF EXISTS, DROP TABLE IF EXISTS, DROP INDEX IF EXISTS and DROP VIEW IF EXISTS of something that is not there, CREATE INDEX IF NOT EXISTS and CREATE VIEW IF NOT EXISTS of one that is there, CREATE EXTENSION, and a COMMENT ON that changes nothing (the same text again, or an object whose comments KDB does not keep). So the CREATE ... IF NOT EXISTS an app sends at start-up never waits for a long writer.
A request that cannot get its locksnone of its statements ran. The error says so: ... (none of the 2 statements of this request was run). Send the whole request again.
Readers and open transactionsA SELECT does not wait for an open transaction between its requests: it runs at once and sees the data without that transaction's changes. A read never waits for another read.
Readers and a running writerA SELECT waits only while a writer runs on a table it reads (a statement, a long CALL, a trigger, a POST /write). SELECT 1, a login lookup and reads of other tables answer at once (readers during a long writer). With reader yield (on unless turned off), a long CALL, trigger or request of many statements lets readers of its tables in between its statements; with it off, they wait for the writer's whole request.
A CALL that only readsis a reader, not a writer: it locks nothing, like a SELECT (a CALL that only reads).
What a reader seesthe last committed state when its request starts, for every table at once. A commit that changes several tables (a trigger that fills three tables) is seen whole or not at all. The statements of one read-only request all see the same state.
IdleA transaction with no statement for 30 seconds is rolled back, so a forgotten transaction cannot block everybody. The time is a setting: see idle transactions.
CommitsWriters that commit at the same moment share one wait for the disk (group commit). Each one is still told "committed" only when its changes are on the disk - unless its session set SET synchronous_commit = off (performance tips): then it is told at once and the disk follows within about 10 ms.
Per databaseThe locks are for one database. Transactions on other databases do not wait.

HTTP: A writer of another table goes at once

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
CREATE TABLE ticks (symbol SYMBOL, price DOUBLE);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1
###
POST /exec

INSERT INTO ticks VALUES ('CITY', 505.5)

Response:

POST /session

HTTP 200 (application/json)

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

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 14, "execute_us": 6}
    },
    {
      "statement": "UPDATE accounts SET balance = balance - 100 WHERE id = 1",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) updated",
      "timings": {"parse_us": 14, "execute_us": 73}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 28, "execute_us": 79}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "INSERT INTO ticks VALUES ('CITY', 505.5)",
  "results": [
    {
      "statement": "INSERT INTO ticks VALUES ('CITY', 505.5)",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) inserted",
      "timings": {"parse_us": 32, "execute_us": 19}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 32, "execute_us": 19}
}

HTTP: Another row of the same table goes at once (row-level locks)

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1
###
POST /exec

UPDATE accounts SET balance = 0 WHERE id = 2

Response:

POST /session

HTTP 200 (application/json)

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

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 12, "execute_us": 4}
    },
    {
      "statement": "UPDATE accounts SET balance = balance - 100 WHERE id = 1",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) updated",
      "timings": {"parse_us": 12, "execute_us": 42}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 24, "execute_us": 46}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "UPDATE accounts SET balance = 0 WHERE id = 2",
  "results": [
    {
      "statement": "UPDATE accounts SET balance = 0 WHERE id = 2",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) updated",
      "timings": {"parse_us": 23, "execute_us": 34}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 23, "execute_us": 34}
}

HTTP: The same row waits 5 seconds, then gets 'a row of table ... is locked'

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1
###
POST /exec

UPDATE accounts SET balance = 0 WHERE id = 1

Response:

POST /session

HTTP 200 (application/json)

{"session": "2fcad7bfc65a0a969b174771e5c08a0e", "idle_timeout_ms": 600000}

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 11, "execute_us": 3}
    },
    {
      "statement": "UPDATE accounts SET balance = balance - 100 WHERE id = 1",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) updated",
      "timings": {"parse_us": 11, "execute_us": 39}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 22, "execute_us": 42}
}

POST /exec

HTTP 503 (application/json)

{
  "query": "UPDATE accounts SET balance = 0 WHERE id = 1",
  "results": [],
  "saved": 0,
  "rolled_back": false,
  "error": "a row of table 'accounts' is locked: another transaction changed it or took it with SELECT ... FOR UPDATE, and is still open; waited 5013 ms (lock_timeout: SET lock_timeout, or kdb serve --lock-timeout). Try again, or keep transactions short",
  "sqlstate": "55P03"
}

HTTP: A transaction that inserted keeps the whole table: another writer waits, then gets 'table ... is busy'

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; INSERT INTO accounts VALUES (3, 'Hari', 0)
###
POST /exec

UPDATE accounts SET balance = 0 WHERE id = 2

Response:

POST /session

HTTP 200 (application/json)

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

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; INSERT INTO accounts VALUES (3, 'Hari', 0)",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 9, "execute_us": 5}
    },
    {
      "statement": "INSERT INTO accounts VALUES (3, 'Hari', 0)",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) inserted",
      "timings": {"parse_us": 9, "execute_us": 47}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 18, "execute_us": 52}
}

POST /exec

HTTP 503 (application/json)

{
  "query": "UPDATE accounts SET balance = 0 WHERE id = 2",
  "results": [],
  "saved": 0,
  "rolled_back": false,
  "error": "table 'accounts' is busy: another transaction has held it for 5027 ms; waited 5003 ms (lock_timeout: SET lock_timeout, or kdb serve --lock-timeout). Try again, or keep transactions short",
  "sqlstate": "55P03"
}

HTTP: An INSERT of a key that is taken gets 23505 at once, even while the table is busy

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1
###
POST /exec

INSERT INTO accounts VALUES (2, 'Hari', 50)

Response:

POST /session

HTTP 200 (application/json)

{"session": "3663ba76f5b8fcea203f7cb874c34c46", "idle_timeout_ms": 600000}

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 24, "execute_us": 5}
    },
    {
      "statement": "UPDATE accounts SET balance = balance - 100 WHERE id = 1",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) updated",
      "timings": {"parse_us": 24, "execute_us": 56}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 48, "execute_us": 61}
}

POST /exec

HTTP 400 (application/json)

{
  "query": "INSERT INTO accounts VALUES (2, 'Hari', 50)",
  "results": [],
  "saved": 0,
  "rolled_back": false,
  "error": "a row with id='2' already exists in 'accounts'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it",
  "sqlstate": "23505",
  "table": "accounts",
  "constraint": "accounts_pkey",
  "column_name": "id"
}

HTTP: CREATE TABLE IF NOT EXISTS of a table that is there goes at once, even while a transaction is open

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1
###
POST /exec

CREATE TABLE IF NOT EXISTS accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS owner STRING

Response:

POST /session

HTTP 200 (application/json)

{"session": "938dc0eedc33ef8407f39dac500ead73", "idle_timeout_ms": 600000}

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 17, "execute_us": 4}
    },
    {
      "statement": "UPDATE accounts SET balance = balance - 100 WHERE id = 1",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) updated",
      "timings": {"parse_us": 17, "execute_us": 47}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 34, "execute_us": 51}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "CREATE TABLE IF NOT EXISTS accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);\nALTER TABLE accounts ADD COLUMN IF NOT EXISTS owner STRING",
  "results": [
    {
      "statement": "CREATE TABLE IF NOT EXISTS accounts (\n  id INT PRIMARY KEY,\n  owner STRING NOT NULL,\n  balance DECIMAL(2) NOT NULL\n)",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "table 'accounts' already exists",
      "timings": {"parse_us": 17, "execute_us": 1}
    },
    {
      "statement": "ALTER TABLE accounts ADD COLUMN IF NOT EXISTS owner STRING",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "table 'accounts' not changed (already as asked)",
      "timings": {"parse_us": 17, "execute_us": 0}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 34, "execute_us": 1}
}

Isolation: what a transaction sees

Syntax:

BEGIN [ISOLATION LEVEL READ COMMITTED | SERIALIZABLE]
SET TRANSACTION ISOLATION LEVEL READ COMMITTED        -- this transaction only (inside BEGIN)
SET default_transaction_isolation = 'read committed'  -- the session's transactions from now on
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED
SHOW transaction_isolation

KDB's transactions are serializable unless you ask for READ COMMITTED: every table a transaction reads or writes is locked until COMMIT or ROLLBACK. PostgreSQL's default is READ COMMITTED.

SERIALIZABLE (KDB's default)READ COMMITTED
A SELECT in the transactionlocks the table until the endlocks nothing, unless the transaction already holds the table
Reading again after another session committedthe same rows (the other session had to wait)the new rows
"Read v, write v + 1" from many clientsnever loses an update; clients take turnscan lose updates, as in PostgreSQL: use UPDATE ... SET v = v + 1 or SELECT ... FOR UPDATE
Tables the transaction wroteread inside it: it sees its own changesthe same
ErrorSQLSTATEHTTPWhat to do
table 'x' is busy inside BEGIN ... COMMIT40001 (serialization_failure)503ROLLBACK and run the whole transaction again. Drivers and frameworks retry this code by themselves.
table 'x' is busy for a statement outside a transaction55P03 (lock_not_available)503send it again
deadlock: ...40P01 (deadlock_detected)503the transaction was rolled back: run it again
FOR UPDATE NOWAIT on a busy table55P03503as in PostgreSQL

HTTP: READ COMMITTED: another session writes the table this transaction read

Setup:

CREATE TABLE counter (k INT PRIMARY KEY, v LONG);
INSERT INTO counter VALUES (1, 10);
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN ISOLATION LEVEL READ COMMITTED
###
POST /exec
X-KDB-Session: {{session}}

SELECT v FROM counter WHERE k = 1
###
POST /exec

UPDATE counter SET v = 11 WHERE k = 1
###
POST /exec
X-KDB-Session: {{session}}

SELECT v FROM counter WHERE k = 1
###
POST /exec
X-KDB-Session: {{session}}

COMMIT

Response:

POST /session

HTTP 200 (application/json)

{"session": "29d98ffec3c666650a313a55dbe88401", "idle_timeout_ms": 600000}

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN ISOLATION LEVEL READ COMMITTED",
  "results": [
    {
      "statement": "BEGIN ISOLATION LEVEL READ COMMITTED",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 8, "execute_us": 10}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 8, "execute_us": 10}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "SELECT v FROM counter WHERE k = 1",
  "results": [
    {
      "statement": "SELECT v FROM counter WHERE k = 1",
      "columns": [
        {"name": "v", "type": "LONG"}
      ],
      "dataset": [
        [10]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 40, "execute_us": 64}
    }
  ],
  "columns": [
    {"name": "v", "type": "LONG"}
  ],
  "dataset": [
    [10]
  ],
  "count": 1,
  "timings": {"parse_us": 40, "execute_us": 64}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "UPDATE counter SET v = 11 WHERE k = 1",
  "results": [
    {
      "statement": "UPDATE counter SET v = 11 WHERE k = 1",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 1,
      "message": "1 row(s) updated",
      "timings": {"parse_us": 19, "execute_us": 50}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 19, "execute_us": 50}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "SELECT v FROM counter WHERE k = 1",
  "results": [
    {
      "statement": "SELECT v FROM counter WHERE k = 1",
      "columns": [
        {"name": "v", "type": "LONG"}
      ],
      "dataset": [
        [11]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 4, "execute_us": 112}
    }
  ],
  "columns": [
    {"name": "v", "type": "LONG"}
  ],
  "dataset": [
    [11]
  ],
  "count": 1,
  "timings": {"parse_us": 4, "execute_us": 112}
}

POST /exec

HTTP 200 (application/json)

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

Read-only transactions

Syntax:

BEGIN READ ONLY                               -- also START TRANSACTION READ ONLY (PostgreSQL port)
BEGIN ISOLATION LEVEL READ COMMITTED, READ ONLY
SET TRANSACTION READ ONLY | READ WRITE        -- this transaction only (inside BEGIN)
SET default_transaction_read_only = on | off  -- the session's transactions from now on
SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY
SHOW transaction_read_only

A read-only transaction refuses every change, as in PostgreSQL. Use it for reports and checks that must never write by mistake.

In a read-only transactionResult
SELECT, SHOW, EXPLAIN, cursors, a CALL of a procedure that only readsruns
INSERT, UPDATE, DELETE, TRUNCATE, COPY ... FROMrefused
CREATE, ALTER, DROP of tables, views, indexes, procedures, databases and usersrefused
SELECT ... FOR UPDATE, WITH x AS (UPDATE ...), nextval(), setval()refused
a CALL or DO block whose body changes datarefused at the first change

HTTP: A report session that cannot write by mistake (shows an error)

Setup:

CREATE TABLE report_src (k INT PRIMARY KEY, v LONG);
INSERT INTO report_src VALUES (1, 10);
POST /exec

BEGIN READ ONLY; SELECT v FROM report_src; COMMIT
###
POST /exec

BEGIN READ ONLY; UPDATE report_src SET v = 0 WHERE k = 1

Response:

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN READ ONLY; SELECT v FROM report_src; COMMIT",
  "results": [
    {
      "statement": "BEGIN READ ONLY",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 7, "execute_us": 3}
    },
    {
      "statement": "SELECT v FROM report_src",
      "columns": [
        {"name": "v", "type": "LONG"}
      ],
      "dataset": [
        [10]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 7, "execute_us": 54}
    },
    {
      "statement": "COMMIT",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction committed: 0 change(s) saved",
      "timings": {"parse_us": 7, "execute_us": 2}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 21, "execute_us": 59}
}

POST /exec

HTTP 403 (application/json)

{
  "query": "BEGIN READ ONLY; UPDATE report_src SET v = 0 WHERE k = 1",
  "results": [
    {
      "statement": "BEGIN READ ONLY",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 14, "execute_us": 4}
    }
  ],
  "saved": 0,
  "rolled_back": true,
  "error": "cannot execute UPDATE in a read-only transaction (the transaction was rolled back)",
  "sqlstate": "25006"
}

How long a writer waits: lock_timeout

Syntax:

kdb serve --lock-timeout 20s              the server (default 5s; 0 = no limit)
KDB_LOCK_TIMEOUT=20s                      the same, for Docker
"lock_timeout_ms": 20000                  the same, in kdb.conf
SET lock_timeout = '20s' | 20000 | 0 | DEFAULT     one session (HTTP session or PostgreSQL connection)
X-KDB-Lock-Timeout: 20s                   one HTTP request
WhereDetail
The serverA flag wins, then kdb.conf, then KDB_LOCK_TIMEOUT, then 5 seconds. The console's Settings page shows the value and where it came from.
One sessionSET lock_timeout lasts for the session, like in PostgreSQL. A plain number is milliseconds. DEFAULT (or RESET lock_timeout) goes back to the server's. SHOW lock_timeout gives the value in milliseconds.
One requestThe HTTP header X-KDB-Lock-Timeout sets it for that request only: /exec, the line protocol (POST /write) and CSV import (POST /imp).
LoadsRows from the line protocol, /imp and a PostgreSQL COPY wait in a write queue. Each caller keeps its own lock timeout there, also while it waits behind other loads of the same table. A cancel or a server stop ends its wait at once, and nothing of it is written.
0Wait without a limit. A statement timeout, a cancel or a server stop still ends the wait at once.
The errortable 'x' is busy: another transaction has held it for N ms; waited M ms .... When only writers that came first are in the way (they wait too), it says other writers came first and wait for it too.

Many small writers of one table (counters, logins) that should queue instead of failing: raise the lock timeout for them, and keep each transaction short.

LOCK TABLE, pg_locks and lock waits

Syntax:

LOCK [TABLE] name [, name ...] [IN mode MODE] [NOWAIT]      only inside BEGIN ... COMMIT
mode: ACCESS SHARE | ROW SHARE | ROW EXCLUSIVE | SHARE UPDATE EXCLUSIVE
    | SHARE | SHARE ROW EXCLUSIVE | EXCLUSIVE | ACCESS EXCLUSIVE (the default)
SELECT relation::regclass, mode, granted, pid FROM pg_locks        who holds and who waits
SELECT pg_blocking_pids(pid)                                       who a waiting pid waits for
SET log_lock_waits = on;  SET deadlock_timeout = '1s'              a log line for a long wait

LOCK TABLE takes a table lock in one of PostgreSQL's eight modes and keeps it until the transaction ends (COMMIT or ROLLBACK). The modes keep each other out as in PostgreSQL:

You lock withOther transactions can stillThey wait to
ACCESS SHAREread and change the tabledrop or rebuild it (ACCESS EXCLUSIVE)
ROW SHARE, ROW EXCLUSIVEread and change ittake SHARE or stronger (ROW SHARE: EXCLUSIVE or stronger)
SHAREread it, and take SHARE toochange it (INSERT, UPDATE, DELETE)
SHARE ROW EXCLUSIVE, EXCLUSIVEread itchange it, or lock it in SHARE or stronger
ACCESS EXCLUSIVEnothingread it or change it. A plain SELECT of the table waits too
RuleDetail
Only in a transactionOutside BEGIN ... COMMIT it fails with LOCK TABLE can only be used in transaction blocks (25P01), as in PostgreSQL. In a procedure body it lasts until the request's transaction ends.
NOWAITA table another transaction holds in a conflicting mode fails at once with could not obtain lock on relation "t" (55P03, HTTP 503: try again), and the transaction is rolled back, as in PostgreSQL. Without NOWAIT it waits the lock timeout.
A viewlocks the tables the view reads, in the same mode.
KDB's own writersstill take turns on a table as always (one writer per table): two UPDATEs of one table never run at once. pg_locks shows a writer's lock as RowExclusiveLock (it changes the table), RowShareLock (SELECT ... FOR UPDATE) or AccessShareLock (it reads the table in its transaction).
ReadersA plain SELECT waits only for ACCESS EXCLUSIVE. A read that started before the lock was granted runs to its end. The session that holds the lock reads on.
DeadlockTwo transactions that wait for each other: one gets deadlock: process 12 waits for ExclusiveLock on table 't1'; blocked by process 34. Process 34 waits for ... (40P01, HTTP 503) at once and is rolled back. Run it again.
RightsACCESS SHARE needs the right to read the table; the other modes the right to change it. A read-only transaction may take up to ROW EXCLUSIVE.

pg_locks lists the table locks of the database: one row per table and mode, granted true for a held lock and false for one waited for (then waitstart says since when). pid is the same number pg_stat_activity and pg_backend_pid() give for that connection or HTTP session. A lock of every table (a schema change, BACKUP) shows as AccessExclusiveLock on each table. Over the PostgreSQL port relation::regclass gives the table's name; over HTTP join pg_class on oid.

pg_locks, pg_stat_activity and pg_stat_statements read no table, so a transaction can look at them (BEGIN; LOCK TABLE t; SELECT * FROM pg_locks) without taking any other table and without waiting for anyone.

pg_blocking_pids(pid) gives the pids a waiting pid waits for: those holding a lock in its way, and those that came first and wait for the same table. {} when it does not wait.

log_lock_waits (off by default) writes one line to the server log when a statement has waited for a table lock longer than deadlock_timeout (1 second by default), naming it, the lock, who holds it and who waits: kdb: LOG: process 12 still waiting for RowExclusiveLock on relation 16448 ('t') of database 'main' after 1003 ms; process holding the lock: 34; wait queue: 12. Set it for the server with kdb serve --log-lock-waits on --deadlock-timeout 1s (KDB_LOG_LOCK_WAITS, KDB_DEADLOCK_TIMEOUT), or for one session with SET. KDB finds a deadlock at once whatever deadlock_timeout is: it only says when the line is written.

HTTP: SHARE mode: another transaction reads the table, its UPDATE would wait; pg_locks shows the lock

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; LOCK TABLE accounts IN SHARE MODE
###
POST /exec

SELECT owner, balance FROM accounts ORDER BY id;
SELECT c.relname, l.mode, l.granted FROM pg_locks l JOIN pg_class c ON c.oid = l.relation

Response:

POST /session

HTTP 200 (application/json)

{"session": "18667f6568b87caad682354243a60541", "idle_timeout_ms": 600000}

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; LOCK TABLE accounts IN SHARE MODE",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 7, "execute_us": 4}
    },
    {
      "statement": "LOCK TABLE accounts IN SHARE MODE",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "LOCK TABLE",
      "timings": {"parse_us": 7, "execute_us": 3}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 14, "execute_us": 7}
}

POST /exec

HTTP 200 (application/json)

{
  "query": "SELECT owner, balance FROM accounts ORDER BY id;\nSELECT c.relname, l.mode, l.granted FROM pg_locks l JOIN pg_class c ON c.oid = l.relation",
  "results": [
    {
      "statement": "SELECT owner, balance FROM accounts ORDER BY id",
      "columns": [
        {"name": "owner", "type": "STRING"},
        {"name": "balance", "type": "DECIMAL(2)"}
      ],
      "dataset": [
        ["Ram", "1000.00"],
        ["Sita", "0.00"]
      ],
      "count": 2,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 18, "execute_us": 64}
    },
    {
      "statement": "SELECT c.relname, l.mode, l.granted FROM pg_locks AS l JOIN pg_class AS c ON c.oid = l.relation",
      "columns": [
        {"name": "relname", "type": "STRING"},
        {"name": "mode", "type": "STRING"},
        {"name": "granted", "type": "BOOL"}
      ],
      "dataset": [
        ["accounts", "ShareLock", true]
      ],
      "count": 1,
      "truncated": false,
      "rows_affected": 0,
      "message": "",
      "timings": {"parse_us": 18, "execute_us": 131}
    }
  ],
  "columns": [
    {"name": "relname", "type": "STRING"},
    {"name": "mode", "type": "STRING"},
    {"name": "granted", "type": "BOOL"}
  ],
  "dataset": [
    ["accounts", "ShareLock", true]
  ],
  "count": 1,
  "timings": {"parse_us": 36, "execute_us": 195}
}

HTTP: NOWAIT on a table another transaction holds: 55P03 at once

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL);
INSERT INTO accounts VALUES (1, 'Ram', 1000), (2, 'Sita', 0);
POST /session

###
POST /exec
X-KDB-Session: {{session}}

BEGIN; LOCK TABLE accounts IN SHARE MODE
###
POST /exec

BEGIN; LOCK TABLE accounts IN ACCESS EXCLUSIVE MODE NOWAIT

Response:

POST /session

HTTP 200 (application/json)

{"session": "4715e1b78204942da6d204e9b1644327", "idle_timeout_ms": 600000}

POST /exec

HTTP 200 (application/json)

{
  "query": "BEGIN; LOCK TABLE accounts IN SHARE MODE",
  "results": [
    {
      "statement": "BEGIN",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "transaction started",
      "timings": {"parse_us": 8, "execute_us": 4}
    },
    {
      "statement": "LOCK TABLE accounts IN SHARE MODE",
      "columns": [],
      "dataset": [],
      "count": 0,
      "truncated": false,
      "rows_affected": 0,
      "message": "LOCK TABLE",
      "timings": {"parse_us": 8, "execute_us": 1}
    }
  ],
  "columns": [],
  "dataset": [],
  "count": 0,
  "timings": {"parse_us": 16, "execute_us": 5}
}

POST /exec

HTTP 503 (application/json)

{
  "query": "BEGIN; LOCK TABLE accounts IN ACCESS EXCLUSIVE MODE NOWAIT",
  "results": [],
  "saved": 0,
  "rolled_back": false,
  "error": "could not obtain lock on relation \"accounts\" (none of the 2 statements of this request was run)",
  "sqlstate": "55P03"
}

Idle transactions: idle_in_transaction_session_timeout

Syntax:

kdb serve --idle-in-transaction-timeout 5min    the server (default 30s; 0 = never)
KDB_IDLE_IN_TRANSACTION_TIMEOUT=5min            the same, for Docker
"idle_in_transaction_timeout_ms": 300000        the same, in kdb.conf
SET idle_in_transaction_session_timeout = '5min' | 300000 | 0 | DEFAULT   one session

An open BEGIN ... COMMIT that sends no statement for this long is rolled back. This happens when another writer needs its tables, or at its own next statement. Its session then gets an error until it sends ROLLBACK. So a client that died in the middle of a transaction cannot keep its tables locked for ever.

WhereDetail
The serverA flag wins, then kdb.conf, then KDB_IDLE_IN_TRANSACTION_TIMEOUT, then 30 seconds. The start-up lines and the console's Settings page show it and where it came from.
One sessionSET idle_in_transaction_session_timeout lasts for the session (an HTTP session or a PostgreSQL connection), like in PostgreSQL. A plain number is milliseconds. 0 = never. DEFAULT (or RESET) goes back to the server's. SHOW idle_in_transaction_session_timeout gives it in milliseconds on the PostgreSQL port.
The errorthe transaction of this session was rolled back (it was open without a statement for N ms; the limit is M ms ...).

Readers during a long writer

Syntax:

kdb serve --per-table-reads on|off     (default on)
KDB_PER_TABLE_READS=off                (the same, for Docker; a flag wins)

A reader waits only for writers that are running right now on a table it reads.

ReaderWhile a long CALL, trigger or bulk load writes table t
SELECT 1, a read of other tablesanswers at once
A read of t (or of a table the writer only reads)waits for the statement running now: the writer lets it in between its statements (reader yield, on by default). With reader yield off, it waits until the writer's request ends. With snapshot reads on, it does not wait
SHOW ..., information_schema, pg_catalogwaits: it reads every table (not pg_locks, pg_stat_activity, pg_stat_statements: they read no table)
Any read, while a writer runs dynamic SQL (EXECUTE format(...)) or a schema change inside a CALLwaits: that writer may touch every table
During a checkpoint
Writing the partition files (can take seconds)readers go on; writers wait
Taking open transactions out, publishing the new fileseverybody waits (milliseconds)

Readers of the writer's tables: reader yield

Syntax:

kdb serve --reader-yield on|off        (default on)
KDB_READER_YIELD=off                   (the same, for Docker; a flag wins)

A writer's request can run for many seconds: a CALL that builds a day of candles, an immediate trigger, a request with many statements. Readers never see its changes before it commits. Readers of other tables never wait for it (above). For readers of its tables, this switch decides whether they wait meanwhile. The start-up lines say which one is in use (readers ...).

offon (default)
A SELECT of the writer's tables while it runswaits until the writer's request endsanswers soon, with the rows as the last commit left them
Howreaders and writers take turns at the databasebetween two statements of the writer's body or request, the writer steps aside and lets waiting readers in. Its table locks stay, so no other writer gets its tables.
Costnonea pause is free when nobody waits. A reader of a table the writer already changed makes the writer take its changes out and put them back, so the writer keeps at least about two thirds of its time.

Readers of the writer's tables: snapshot reads

Syntax:

kdb serve --snapshot-reads on|off      (default off)
KDB_SNAPSHOT_READS=on                  (the same, for Docker; a flag wins)

With snapshot reads on, a reader of a table that a writer is changing does not wait, not even for one long statement. It reads a snapshot of the table: the rows committed when the read started.

reader yield onlysnapshot reads on
A SELECT of t while a long CALL, trigger job or INSERT ... SELECT writes twaits for the statement running nowanswers at once, with the committed rows
A SELECT of t while a session's BEGIN holds changes of tanswers at once (the transaction is taken out for it)answers at once (the transaction stays in)
A read-only CALL of twaits like a SELECTreads snapshots, each statement of its body the rows committed when it starts
What the writer paystakes its changes out and puts them back for readersnothing: when a reader holds a snapshot of a partition it changes next, it leaves the snapshot's unsaved rows as they are and writes on a new layer on top (since 2026-10-02; before, it copied them all)
FieldWhat it counts
snapshot_delta_freezeslayers frozen because a snapshot held them when a writer came
snapshot_delta_foldslayers folded back once no snapshot held them
snapshot_delta_mergespairs of layers made one (past 6 layers in a partition)
snapshot_delta_copiesfull copies of a partition's unsaved rows (only an ALTER TABLE makes one now)
snapshot_delta_layersfrozen layers the tables hold right now (0 when the stats are read while a job runs)

A snapshot_delta_layers that stays high while nobody reads means a partition was not written since its last snapshot ended: the next write or checkpoint frees it.

Several writers of one table

Syntax:

kdb serve --table-writers one|many     (default one)
KDB_TABLE_WRITERS=many                 (the same, for Docker; a flag wins)

With many, several users change one table at the same time, as long as each of their requests touches the table only by single rows and their primary key:

Allowed on the shared tableNot allowed (the request takes the whole table, as before)
INSERT INTO t VALUES (...) (a row, or a few)INSERT ... SELECT, ON CONFLICT DO UPDATE
UPDATE t SET ... WHERE id = x (x a value, a parameter or a procedure variable; the key itself is not set)UPDATE ... WHERE balance > 0, UPDATE ... FROM, an UPDATE without WHERE
DELETE FROM t WHERE id = xDELETE of a range, with USING or RETURNING
SET v = (SELECT a, b FROM t WHERE id = x) in a procedure or trigger bodya SELECT statement, a FOR loop's query, a view, a CTE, a join
a CALL or a trigger made of the statements aboveBEGIN ... COMMIT across requests, LOCK TABLE, SELECT ... FOR UPDATE

The table must be a plain table with a one-column PRIMARY KEY, no foreign key either way, no other UNIQUE column or index, no AUTO column, no FOR EACH ROW trigger and no materialized view built from it (statement triggers are fine). Other tables of the same request are not affected: each is held shared or whole on its own.

Big transactions: log records on disk

Syntax:

kdb serve --txn-spill-mb 64            (default 64; 0 = never)
KDB_TXN_SPILL_MB=64                    (the same, for Docker; a flag wins)

A transaction writes its changes to the log only at COMMIT. Until then KDB keeps the log records (the changes, ready to be written). A long CALL, a deferred trigger job or a big BEGIN ... COMMIT can make many of them.

Since 2026-10-02, once they pass 64 MB, KDB moves them to a temporary file in the database's _tmp folder. Only where each record starts stays in memory.

WhenWhat happens to the file
COMMITread back in order and written into the log, then removed
ROLLBACKremoved
ROLLBACK TO SAVEPOINTcut back to where the savepoint was
a crashremoved when the database opens again (those records were never committed)

Example: The line where log records go to disk

SELECT name, setting, unit, source FROM pg_settings WHERE name = 'txn_spill_mb';

Result:

name (STRING)setting (STRING)unit (STRING)source (STRING)
txn_spill_mb64MBdefault

(1 row)

Accounting with CHECK constraints

Syntax:

balance DECIMAL(2) NOT NULL CHECK (balance >= 0)
BEGIN; UPDATE ...; UPDATE ...; INSERT INTO ledger ...; COMMIT;

A CHECK constraint (a rule that every row must follow, see constraints) makes the database itself refuse a balance below zero. Together with a transaction, a transfer that would break the rule saves nothing.

Checklist for money:

  1. Money columns are DECIMAL(2) NOT NULL (exact, never DOUBLE), with CHECK rules for what must never happen.
  2. Keep a ledger. Never update or delete its rows; correct a mistake with a new transfer back.
  3. Every transfer: BEGIN, UPDATE ... SET balance = balance - amount, UPDATE ... + amount, INSERT INTO ledger, COMMIT. Let the database do the math; do not write a balance that your app computed.
  4. On any error: ROLLBACK. On table ... is busy, database is busy or deadlock: run the whole transaction again.
  5. Check the books every night (the total, and every balance against the ledger), like in the first example of this page.

Example: A transfer of more than the balance saves nothing (shows an error)

Setup:

CREATE TABLE accounts (
  id INT PRIMARY KEY,
  owner STRING NOT NULL,
  balance DECIMAL(2) NOT NULL DEFAULT 0 CHECK (balance >= 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,
  CONSTRAINT positive_amount CHECK (amount > 0)
);
INSERT INTO accounts VALUES (1, 'Ram', 749.50), (2, 'Sita', 250.50);
BEGIN;
UPDATE accounts SET balance = balance + 900 WHERE id = 2;
UPDATE accounts SET balance = balance - 900 WHERE id = 1;
INSERT INTO ledger (from_account, to_account, amount) VALUES (1, 2, 900);
COMMIT;

Result:

BEGIN

transaction started

UPDATE accounts SET balance = balance + 900 WHERE id = 2

1 row(s) updated

Error: CHECK constraint 'accounts_balance_check' of table 'accounts' failed: balance >= 0 is false for the row id='1', balance='-150.50' (the transaction was rolled back)

Without CHECK constraints (or in an older KDB), a BEFORE UPDATE trigger can refuse the same change: see triggers.