SQL / SECURITY, ROUTINES, AND DIALECTS
Stored procedures: packaging logic inside the database
Package multi-statement work in a stored procedure, call it with CALL, return values through OUT parameters, and control transactions from inside the body.
What you will learn
- Package a multi-statement operation with CREATE PROCEDURE and run it via CALL or EXEC
- Hand values back from a procedure through OUT parameters, not a return value
- Use COMMIT inside a procedure body to split long batch work into many transactions
- Predict which work survives when a procedure fails after committing a batch
Understanding Stored procedures: packaging logic inside the database
A stored procedure is a program the server keeps in its own catalog under a name, held as source text and turned into a plan when a session first runs it, and invoked as a statement in its own right: CALL transfer(1, 2, 200.00) in PostgreSQL and MySQL, EXEC transfer 1, 2, 200.00 in SQL Server. The point is not saving typing, it is moving a sequence of statements to the side of the connection where the data lives, so a debit and its matching credit happen with no network hop between them and no chance that a dropped client leaves half the work done. Because a procedure is a statement and not an expression, it has no return type and cannot appear in a SELECT list or a subquery; it exists to change something, not to produce a value.
The dividing line worth memorising is transaction ownership. A function is evaluated inside a statement that is already running inside a transaction, so it has no coherent point at which it could commit; a procedure invoked by CALL is itself the top-level statement, so it may issue COMMIT and ROLLBACK, which is what makes procedures the right shape for backfills and queue draining that would otherwise hold one enormous transaction open. The price is that atomicity becomes your decision: a body with no COMMIT in it is all-or-nothing, while one that commits every thousand rows leaves the committed prefix in place if it fails at row five thousand.
Choosing what belongs in a procedure is mostly a question of who must be protected from what. Rules that have to hold no matter which client connects, the reporting script, the mobile API, a person with a psql prompt open, belong on the server, as does bulk maintenance that would be pointless to stream over the wire row by row. Everything else pays real costs: bodies are written in a language that does not port (PL/pgSQL, T-SQL, MySQL's SQL/PSM), they are awkward to unit test and to read in a diff, and they ship by DDL rather than with your build. A procedure that mainly formats output or stitches together two unrelated features is usually application code that ended up in the wrong process.
-- PostgreSQL
CREATE TABLE accounts (
id int PRIMARY KEY,
holder text NOT NULL,
balance numeric(10,2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts VALUES (1, 'ana', 500.00), (2, 'bo', 50.00);
CREATE PROCEDURE transfer(p_from int, p_to int, p_amount numeric)
LANGUAGE plpgsql
AS $$
DECLARE
src numeric;
BEGIN
SELECT balance INTO src FROM accounts WHERE id = p_from FOR UPDATE;
IF src IS NULL THEN
RAISE EXCEPTION 'account % does not exist', p_from;
END IF;
IF src < p_amount THEN
RAISE EXCEPTION 'account % has % but needs %', p_from, src, p_amount;
END IF;
UPDATE accounts SET balance = balance - p_amount WHERE id = p_from;
UPDATE accounts SET balance = balance + p_amount WHERE id = p_to;
END;
$$;
CALL transfer(1, 2, 200.00);
SELECT id, holder, balance FROM accounts ORDER BY id;A stored procedure is a named server-side program invoked as a top-level statement, which is why it can contain many statements, control flow, and its own transaction boundaries.
Worked examples
Committing in batches from inside the body
Shows the one thing a procedure can do that a function cannot: end and start transactions while it runs.
-- PostgreSQL
CREATE TABLE jobs (id int PRIMARY KEY, done boolean NOT NULL DEFAULT false);
INSERT INTO jobs (id) SELECT g FROM generate_series(1, 5) AS g;
CREATE PROCEDURE drain_jobs(p_batch int)
LANGUAGE plpgsql
AS $$
DECLARE
n int;
BEGIN
LOOP
UPDATE jobs SET done = true
WHERE id IN (SELECT id FROM jobs WHERE NOT done ORDER BY id LIMIT p_batch);
GET DIAGNOSTICS n = ROW_COUNT;
EXIT WHEN n = 0;
RAISE NOTICE 'committed % row(s)', n;
COMMIT;
END LOOP;
END;
$$;
CALL drain_jobs(2);Example explained
Line 1GET DIAGNOSTICS n = ROW_COUNT reads how many rows the previous UPDATE touched, which is how the loop learns there is nothing left.
Line 2COMMIT ends the current transaction and immediately starts a fresh one, so each pair of rows is durable before the next pair is even selected.
Line 3EXIT WHEN n = 0 stops on the fourth pass, after an UPDATE that matches nothing, so the last notice reports one row rather than two.
Line 4The notices arrive before the CALL tag because they are sent while the procedure is still executing, not after it returns.
Getting a generated id back with OUT
Demonstrates how a procedure returns data when it has no return type.
-- PostgreSQL
CREATE TABLE tickets (
id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL
);
CREATE PROCEDURE open_ticket(p_title text, OUT new_id int)
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO tickets (title) VALUES (p_title)
RETURNING id INTO new_id;
END;
$$;
CALL open_ticket('printer on fire', NULL);Example explained
Line 1OUT new_id int declares an output slot; since the procedure has no return type, this is the only channel back to the caller.
Line 2RETURNING id INTO new_id copies the identity value the INSERT generated, avoiding a second round trip to read it.
Line 3The NULL in the CALL is only a placeholder that fills the argument position; PostgreSQL never evaluates arguments matching OUT parameters.
Line 4Because output parameters exist, CALL sends back a one-row result set named after the parameter instead of the bare CALL tag.
Important notes
CREATE OR REPLACE PROCEDURE only replaces a procedure with the same parameter list; add or drop a parameter and you get a second overload under the same name, and callers may keep hitting the old one.
PostgreSQL returns data only through OUT parameters or refcursors, while MySQL and SQL Server procedures can stream a plain result set to the client, so a ported procedure usually changes shape.
Common mistakes
Calling it like a function: SELECT transfer(1, 2, 10) fails with 'transfer(integer, integer, numeric) is a procedure' because CALL is a statement and cannot sit in a SELECT list or subquery.
Leaving COMMIT in the body and then running the procedure inside a client-side BEGIN ... COMMIT block, which aborts with 'invalid transaction termination' since the procedure does not own the transaction it was handed.
Naming a parameter after a column, so WHERE id = id compares the column to itself: PostgreSQL raises 'column reference id is ambiguous', while MySQL prefers the parameter and silently updates every row in the table.
Try it yourself
Change, predict, then run
In the editor, create stock(sku text primary key, qty int not null check (qty >= 0)) holding one row with qty 10, then write a procedure ship(p_sku text, p_qty int) that raises an exception when the shipment exceeds the stock on hand and otherwise subtracts it. Call it for 4 units and then for 100, selecting the table after each call to see which one left the row untouched.
Open the SQL workspaceCheck your understanding
A procedure updates 1,000 rows in batches of 100 and issues COMMIT after each batch. The seventh batch raises an error. What does the table look like afterwards?
- The first six batches remain applied and the seventh is rolled back
- Nothing is applied, because the whole CALL is a single transaction
- All 1,000 rows are applied, because COMMIT already accepted the earlier batches
- The first six batches are rolled back when the error escapes the procedure
Show answer
Each COMMIT ends a transaction and makes that batch durable, so nothing can later reach back and undo it; the failure only rolls back the transaction that was open at the time, which contains just the seventh batch. Option two describes what would happen with no COMMIT in the body, or with a function, where the single statement is the whole transaction.