Lesson 12 / 20
Stored Procedures and Functions
The standard form of server-side procedures and functions, the distinction between procedure and function, the consequences of determinism, measuring the round-trip count, and comparison with views.
Contents
Up to this point, the transaction’s boundary, what to do on error, and retrying were all decided by application code; the database only took the statements it was sent. Some of this logic can also be defined inside the database itself. If the “lend a book” rule is put into a procedure, the application sends one call instead of two statements; the rule stays in the same place as the data.
A stored procedure and a stored function are code units stored on the server side and called by name. This lesson covers their standard form, the distinction between them, and the trade-offs they bring.
Procedure and Function
What separates them is how they are called and what they return.
A procedure is called with CALL. It is not required to return a value; it can take
input, output, and input-output parameters, and its body can contain statements that change
data.
A function returns a value and can be used anywhere an expression is allowed — in a
SELECT list, in a WHERE condition. It is therefore expected to have no side effects.
The procedural subset of standard SQL writes the lending rule like this:
CREATE PROCEDURE lend_book(IN p_book_id INTEGER, IN p_member_id INTEGER) LANGUAGE SQL BEGIN ATOMIC INSERT INTO loan(book_id, member_id, pickup) VALUES (p_book_id, p_member_id, CURRENT_DATE); UPDATE book SET on_shelf = 0 WHERE id = p_book_id; END;
Its call is one line:
CALL lend_book(1, 4);
The late-days calculation, by contrast, is a function: it returns a value and is used in a query:
CREATE FUNCTION late_days(p_pickup DATE, p_returned DATE) RETURNS INTEGER LANGUAGE SQL DETERMINISTIC RETURN CASE WHEN (COALESCE(p_returned, CURRENT_DATE) - p_pickup) - 14 > 0 THEN (COALESCE(p_returned, CURRENT_DATE) - p_pickup) - 14 ELSE 0 END;
These three blocks are not executed; they are written to show the form. The reason is
that support for the procedural subset varies by engine: some offer a language close to the
standard, some use their own procedural language, and some — including this course’s
observation environment — do not support stored procedures at all. What does not change is
the distinction between CREATE PROCEDURE and CREATE FUNCTION and the CALL form of
invocation.
The BEGIN ATOMIC block states that the statements in the body form an indivisible unit: if
one fails, none of them apply. The atomicity from the transaction control lesson becomes
part of the procedure’s definition here.
A Function Defined in the Application
On an engine that does not support stored procedures, the counterpart of a function is an application function registered on the connection. The code lives in the application, but it runs inside the query — the engine calls it for every row:
cat > function-in-app.mjs <<'JS' import { DatabaseSync } from 'node:sqlite'; const db = new DatabaseSync(':memory:'); db.exec(` CREATE TABLE loan(id INTEGER PRIMARY KEY, member_id INT, pickup TEXT, returned TEXT); INSERT INTO loan VALUES (1,1,'2024-03-01','2024-03-15'),(2,1,'2024-03-04','2024-03-18'), (3,2,'2024-03-06','2024-03-25'),(4,2,'2024-03-11',NULL),(5,7,'2024-03-18','2024-03-24'); `); const DAY = 86400000; db.function('late_days', { deterministic: true }, (pickup, returned) => { const end = returned === null ? Date.parse('2024-03-31') : Date.parse(returned); const days = Math.round((end - Date.parse(pickup)) / DAY); return Math.max(0, days - 14); }); for (const s of db.prepare( `SELECT id, member_id, pickup, COALESCE(returned, '(open)') AS returned, late_days(pickup, returned) AS late FROM loan WHERE late_days(pickup, returned) > 0 ORDER BY id`).all()) { console.log(JSON.stringify(s)); } JS node function-in-app.mjs
{"id":3,"member_id":2,"pickup":"2024-03-06","returned":"2024-03-25","late":5}
{"id":4,"member_id":2,"pickup":"2024-03-11","returned":"(open)","late":6}
Two of the five records exceeded the fourteen-day period. The rule was written in one
place and used in both the SELECT list and the WHERE condition; the same calculation did
not need to be duplicated in two places. This is exactly what a stored function provides —
the difference is only where the code lives.
Determinism
The word DETERMINISTIC in the function definition is a promise: called with the same
arguments, it always returns the same result. That promise gives the planner two
permissions — caching the result instead of recomputing it, and storing the function’s
result in an index.
The second permission can be measured. The script below defines two functions; both have the same body, only one declares itself deterministic. An index is attempted on each:
cat > deterministic.mjs <<'JS' import { DatabaseSync } from 'node:sqlite'; const db = new DatabaseSync(':memory:'); db.exec(`CREATE TABLE loan(id INTEGER PRIMARY KEY, pickup TEXT, returned TEXT);`); db.function('fixed_duration', { deterministic: true }, (pickup, returned) => (returned === null ? -1 : 1)); db.function('variable_duration', (pickup, returned) => (returned === null ? -1 : 1)); for (const [name, fn] of [['fixed_duration', 'fixed_duration'], ['variable_duration', 'variable_duration']]) { try { db.exec(`CREATE INDEX index_${name} ON loan(${fn}(pickup, returned))`); console.log(`${name}: index created`); } catch (error) { console.log(`${name}: ${error.message}`); } } JS node deterministic.mjs
fixed_duration: index created variable_duration: non-deterministic functions prohibited in index expressions
The reason is simple: an index stores a function’s result. If the result can change over time, the value in the index falls out of sync with the data, and queries return the wrong answer. The engine cuts off this danger before it starts.
The same reasoning explains why a non-deterministic function gets called freshly for every row inside a query. A function that reads the current time, a random number, or session information, if declared deterministic, turns the engine’s optimizations into silent errors. Because the declaration is a promise, the responsibility falls on whoever wrote it.
The Round-Trip Cost
The most concrete benefit of server-side logic is reducing the number of round trips between the application and the engine. This cost does not depend on how much work there is: every statement means a separate request and a separate response.
The script below lends two hundred books. First it sends two statements per book, then it does the same work with two set-based statements:
cat > round-trip.mjs <<'JS' import { DatabaseSync } from 'node:sqlite'; const db = new DatabaseSync(':memory:'); db.exec(` CREATE TABLE book(id INTEGER PRIMARY KEY, on_shelf INT NOT NULL); CREATE TABLE loan(id INTEGER PRIMARY KEY, book_id INT, member_id INT, pickup TEXT); `); for (let i = 1; i <= 200; i += 1) db.exec(`INSERT INTO book VALUES (${i}, 1)`); let statements = 0; const run = (sql, ...arg) => { statements += 1; db.prepare(sql).run(...arg); }; statements = 0; db.exec('BEGIN'); for (let i = 1; i <= 200; i += 1) { run('INSERT INTO loan(book_id, member_id, pickup) VALUES (?, 4, ?)', i, '2024-03-11'); run('UPDATE book SET on_shelf = 0 WHERE id = ?', i); } db.exec('COMMIT'); console.log('row by row : statements sent =', statements, '| loan count =', db.prepare('SELECT COUNT(*) AS n FROM loan').get().n); db.exec('DELETE FROM loan; UPDATE book SET on_shelf = 1;'); statements = 0; db.exec('BEGIN'); run(`INSERT INTO loan(book_id, member_id, pickup) SELECT id, 4, '2024-03-11' FROM book WHERE on_shelf = 1`); run('UPDATE book SET on_shelf = 0 WHERE on_shelf = 1'); db.exec('COMMIT'); console.log('set-based : statements sent =', statements, '| loan count =', db.prepare('SELECT COUNT(*) AS n FROM loan').get().n); JS node round-trip.mjs
row by row : statements sent = 400 | loan count = 200 set-based : statements sent = 2 | loan count = 200
The same two hundred records were produced with 2 statements instead of 400. Because the engine and the application share a process here, the effect of the difference is small; in an application talking over a network, 400 statements mean 400 round trips, and the latency of each round trip alone can exceed the cost of the work itself.
This is what a stored procedure earns: the loop stays on the server side, the application makes a single call. But the measurement says something else too — cutting round trips does not require a procedure. The set-based statement achieved the same gain without any stored procedure. A procedure can only be justified by this reasoning when a control flow is needed that cannot be expressed with a set-based statement.
Comparison with a View
Procedures are not the only thing put on the server side. A view names a query and stores it in the schema; it takes no parameters, but it collects repeated join and filter logic in one place:
sqlite3 -box -header <<'SQL' CREATE TABLE member(id INTEGER PRIMARY KEY, name TEXT, branch TEXT); CREATE TABLE loan(id INTEGER PRIMARY KEY, book_id INT, member_id INT, pickup TEXT, returned TEXT); INSERT INTO member VALUES (1,'Alice','Kadikoy'),(2,'Ben','Kadikoy'),(7,'Grace','Kadikoy'); INSERT INTO loan VALUES (1,1,1,'2024-03-01','2024-03-15'),(4,7,1,'2024-03-11',NULL), (9,8,2,'2024-03-21',NULL),(23,2,7,'2024-03-11','2024-03-16'); CREATE VIEW open_loans AS SELECT l.id, m.name, l.pickup FROM loan l JOIN member m ON m.id = l.member_id WHERE l.returned IS NULL; SELECT * FROM open_loans ORDER BY id; SELECT COUNT(*) AS open_count FROM open_loans; SQL
┌────┬───────┬────────────┐ │ id │ name │ pickup │ ├────┼───────┼────────────┤ │ 4 │ Alice │ 2024-03-11 │ │ 9 │ Ben │ 2024-03-21 │ └────┴───────┴────────────┘ ┌────────────┐ │ open_count │ ├────────────┤ │ 2 │ └────────────┘
The definition of an “open loan” stood in one place, and both queries used it. If the definition changes — say, canceled records should also be excluded — a single place gets updated. Unlike a common table expression, a view is persistent and part of the schema.
Putting code on the server side has a cost too. Procedure bodies fit into version control and test suites with more friction than application code does; since every engine’s procedural language differs, portability is lost; debugging tools are more limited than for the application language. The measure is drawn from the nature of the logic placed there: rules that protect data integrity gain from staying close to the data, workflow decisions gain from staying in the application.
Summary
- A procedure is called with
CALLand can change data; a function returns a value and is used inside an expression. - The standard procedural subset is defined with
CREATE PROCEDUREandCREATE FUNCTION; the degree of support varies by engine. - A
DETERMINISTICdeclaration grants the planner caching and indexing permission; a non-deterministic function cannot be used in an index expression. - Server-side logic reduces the round-trip count; in the measurement, 400 statements of work dropped to 2, but the same gain was reached with a set-based statement and no procedure.
- A view is a parameterless server-side abstraction that keeps repeated query logic in one place in the schema.
Next Step
In this lesson, server-side code was always called: the procedure by CALL, the
function by name inside a query. Because it was read from where it was called, what it did
was visible. The database also defines code that runs without being called: triggers, which
fire on their own when a table is written to. The next lesson covers how they are defined,
what they are good for, and why code that runs without being called deserves special care.
To keep your progress and take notes, Log in
My notes
Log in to take notes.