Exercises — Stored procedures, triggers, functions
Before we start, one shared table we will reuse. Picture a plain grid with three columns:

Every row is one bank account. id names the account, balance is how much money sits in it. When we "transfer", we subtract from one row's balance and add the same amount to another row's balance. Keep this picture in your head — most exercises touch it.
Level 1 — Recognition
L1.1 — Name the three tools
For each description, name stored procedure, function, or trigger:
- Runs automatically the moment a row is deleted.
- You type
CALL cleanup();to start it. - Returns one number and can sit inside
SELECT price_with_tax(price).
Recall Solution
- Trigger — the defining trait is automatic firing on a data-change event; nobody calls it.
- Stored procedure — the word
CALLis the giveaway; it is invoked explicitly and need not return a value. - Function — it returns exactly one value and is embedded inside an SQL expression.
L1.2 — Which pseudo-table exists?
State whether OLD, NEW, or both are available inside each trigger body:
AFTER INSERTBEFORE DELETEAFTER UPDATE
Recall Solution
AFTER INSERT→ only NEW (a fresh row appeared; there is no previous version).BEFORE DELETE→ only OLD (a row is about to vanish; there is no incoming version).AFTER UPDATE→ both OLD and NEW (the row changed from one value to another).
Mnemonic from the parent: OLD–DELETE, NEW–INSERT, UPDATE = BOTH.
Level 2 — Application
L2.1 — Write a tax function
Write a MySQL function net_price that takes a price and a tax rate and returns the price including tax. Then compute by hand: net_price(200.00, 0.18).
Recall Solution
DELIMITER //
CREATE FUNCTION net_price(price DECIMAL(10,2), rate DECIMAL(4,3))
RETURNS DECIMAL(10,2)
DETERMINISTIC
BEGIN
RETURN price * (1 + rate);
END //
DELIMITER ;Why RETURNS? The engine must know the output type to slot the result into an expression.
Why DETERMINISTIC? Same inputs always give the same output (no NOW(), no table reads), so it is honestly a pure calculator.
By hand: .
L2.2 — Write the transfer procedure and trace it
Write a procedure transfer(src, dst, amt) that moves money atomically. Starting from account 1 = 500.00 and account 2 = 300.00, trace the balances after CALL transfer(1, 2, 120.00).
Recall Solution
DELIMITER //
CREATE PROCEDURE transfer(IN src INT, IN dst INT, IN amt DECIMAL(10,2))
BEGIN
START TRANSACTION;
UPDATE accounts SET balance = balance - amt WHERE id = src;
UPDATE accounts SET balance = balance + amt WHERE id = dst;
COMMIT;
END //
DELIMITER ;Why the transaction? Either both updates land or neither does — see Transactions and ACID. If the server crashed after the first UPDATE, the missing COMMIT means the whole thing rolls back; money is never lost.
Trace:
- Start: acc1 = 500.00, acc2 = 300.00.
balance - 120on acc1 → 380.00.balance + 120on acc2 → 420.00.- Total before = 800.00, total after = 380 + 420 = 800.00 — conserved, as it must be.
Level 3 — Analysis
L3.1 — Why is this SELECT illegal?
A student writes:
SELECT transfer(1, 2, 100.00);and gets an error. Explain precisely why, using the invocation-context rule.
Recall Solution
transfer is a procedure, not a function. A SELECT evaluates an expression per row and needs each call to return one value with no side effects. But transfer:
- returns nothing (no
RETURNS), - runs
START TRANSACTION ... COMMIT(a side effect the query planner must never trigger mid-scan).
So the engine refuses. Procedures are invoked with CALL transfer(1,2,100.00); standalone. The invocation context — inside an expression vs. standalone CALL — is the dividing line, exactly as the parent note's first Common Mistake warns.
L3.2 — The lying-DETERMINISTIC bug
This function is declared DETERMINISTIC but reads the wall clock:
CREATE FUNCTION age_now(dob DATE)
RETURNS INT
DETERMINISTIC
BEGIN
RETURN TIMESTAMPDIFF(YEAR, dob, NOW());
END //Describe the concrete wrong behaviour this can cause.
Recall Solution
DETERMINISTIC is a promise: "same inputs → same output, forever." The optimizer trusts it and may cache a computed value or reuse it across rows/queries. But NOW() changes with time, so the true output depends on more than dob.
Concrete failure: the DB caches age_now('2000-01-01') = 25 and keeps serving 25 even after a year passes, or uses it to build a functional index (see Indexes) that silently goes stale. Result: silently wrong data with no error message. The fix is to declare it NOT DETERMINISTIC (and READS SQL DATA where relevant), or better, pass the reference date in as a parameter so it becomes honestly pure.
Level 4 — Synthesis
L4.1 — Design an audit trigger with a guard
Design an AFTER UPDATE trigger on employees that appends a row to salary_log(emp_id, old_sal, new_sal, changed_at) only when the salary actually changed. Then, given an UPDATE employees SET dept = 'Sales' WHERE id IN (1,2,3) that touches 3 rows but changes no salaries, state how many log rows appear.
Recall Solution
DELIMITER //
CREATE TRIGGER audit_salary
AFTER UPDATE ON employees
FOR EACH ROW
BEGIN
IF NEW.salary <> OLD.salary THEN
INSERT INTO salary_log(emp_id, old_sal, new_sal, changed_at)
VALUES (OLD.id, OLD.salary, NEW.salary, NOW());
END IF;
END //
DELIMITER ;Why AFTER? We only log a change once it is safely written — we are reacting, not editing.
Why FOR EACH ROW? It fires once per affected row, so both OLD and NEW refer to that one row.
Why the IF? To skip noise when the salary was untouched.
The UPDATE hits 3 rows, so the trigger body runs 3 times. But in each, NEW.salary = OLD.salary (only dept changed), so the guard is false every time. Zero log rows are inserted.
L4.2 — Combine a procedure with a validating trigger
You want a rule: no account balance may ever go negative, enforced even against raw admin SQL. Should this live in the transfer procedure, or in a trigger? Write the enforcement.
Recall Solution
It must live in a BEFORE UPDATE trigger, because a procedure only guards its own code path — a careless admin running a raw UPDATE bypasses it. A trigger fires no matter who touches the table.
DELIMITER //
CREATE TRIGGER no_negative
BEFORE UPDATE ON accounts
FOR EACH ROW
BEGIN
IF NEW.balance < 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Balance cannot go negative';
END IF;
END //
DELIMITER ;Why BEFORE? We must inspect (and reject) the incoming value before it is written — NEW.balance is still editable/inspectable here. In an AFTER trigger the bad value would already be committed.
Why SIGNAL? It raises an error, which forces the surrounding transaction (from transfer) to roll back — tying the invariant into Transactions and ACID.
Test: transfer(1, 2, 999999.00) when acc1 has only 500 → the first UPDATE sets NEW.balance = 500 - 999999 = -999499, the trigger fires, SIGNAL aborts, the transaction rolls back, nothing changes.
Level 5 — Mastery
L5.1 — Trigger-cascade counting
Table A has an AFTER INSERT trigger that inserts one row into table B. Table B has an AFTER INSERT trigger that inserts one row into table C. You run a single INSERT INTO A that adds 4 rows. How many total rows are inserted across A, B, and C?
Recall Solution
FOR EACH ROW triggers fire once per affected row, and inserts cascade:
- Into A: 4 rows (your statement).
- A's trigger fires 4 times → 4 rows into B.
- B's trigger fires 4 times → 4 rows into C.
Total = rows. This is exactly the "spooky action at a distance" the parent note warns about: one visible statement quietly produced twelve writes through hidden chained triggers.
L5.2 — Concurrency at the boundary
Two clients call transfer(1, 2, 100) at the same instant, both reading acc1 = 500 at the start. Without any locking, both compute 500 - 100 = 400 and write it. What is the bug, and what actually prevents it?
Recall Solution
This is a lost update. Both sessions read 500, both write 400 — but two transfers of 100 should have left 300. One update silently overwrote the other; 100 vanished.
What saves us: the UPDATE accounts SET balance = balance - amt form does not read-then-write in the app; it computes inside the engine under a row lock. Whichever transaction grabs the lock on account 1 first applies -100, releases at COMMIT, then the second reads the already-updated 400 and applies -100 → 300. This locking is the subject of Concurrency Control. The dangerous version is when logic reads the balance into a variable in the procedure and writes it back — that reintroduces the race.
L5.3 — Ordering: BEFORE guard vs. the transaction
In L4.2 we added a BEFORE UPDATE guard that SIGNALs on a negative balance, and transfer wraps both updates in a transaction. If the second update (crediting dst) somehow failed, is the first update (debiting src) still applied? Explain the exact timeline.
Recall Solution
Timeline inside transfer:
START TRANSACTION.UPDATE src→ theBEFOREguard runs; if the debit is legal it is applied within the transaction (not yet committed).UPDATE dstfails → an error is raised.- Because we never reach
COMMIT, the transaction is rolled back, and step 2's debit is undone.
So no — the debit is not left behind. Atomicity (Transactions and ACID) means all statements between START TRANSACTION and COMMIT are one indivisible unit: reaching an error before COMMIT reverts everything, including the earlier successful UPDATE. This is the whole reason the money-transfer logic lives server-side in one transactional block.
Connections
- Transactions and ACID — L2.2, L4.2, L5.3 all rest on atomic commit/rollback.
- Concurrency Control — L5.2's lost-update and row locks.
- Indexes — L3.2's stale functional index from a false DETERMINISTIC.
- SQL Queries (SELECT, JOIN) — L3.1's illegal
SELECT transfer(...). - Database Normalization — audit/derived columns maintained by triggers (L4.1).
- Parent: Stored procedures, triggers, functions (index 4.4.12)