05 · Triggers¶
A trigger is SQL code that runs automatically when a row is inserted,
updated, or deleted — no application code has to remember to call it.
Unlike stored procedures (previous module), SQLite fully supports
triggers with real, runnable CREATE TRIGGER syntax. This module uses
that native support directly.
Sample schema¶
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
stock INTEGER NOT NULL,
updated_at TEXT
);
INSERT INTO products (name, stock) VALUES ('Widget', 50), ('Gadget', 10);
CREATE TABLE stock_log (
id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL,
old_stock INTEGER,
new_stock INTEGER,
changed_at TEXT NOT NULL
);
AFTER trigger — audit log on every stock change¶
CREATE TRIGGER trg_stock_audit
AFTER UPDATE OF stock ON products
FOR EACH ROW
BEGIN
INSERT INTO stock_log (product_id, old_stock, new_stock, changed_at)
VALUES (OLD.id, OLD.stock, NEW.stock, datetime('now'));
UPDATE products SET updated_at = datetime('now') WHERE id = NEW.id;
END;
UPDATE products SET stock = stock - 5 WHERE name = 'Widget';
SELECT * FROM products;
id name stock updated_at
-- ------ ----- -------------------
1 Widget 45 2026-08-14 04:31:10
2 Gadget 10
UPDATE OF stock scopes the trigger to fire only when the stock column
specifically changes — updating name alone wouldn't fire it. Inside the
trigger body, OLD refers to the row before the change and NEW to the row
after; AFTER means the trigger body runs once the triggering statement has
already committed its change, so NEW.stock in the INSERT reflects the
already-updated value. Only Widget's row appears in the log because only
its stock was touched — Gadget is untouched by this UPDATE.
BEFORE trigger with a guard condition — blocking bad writes¶
CREATE TRIGGER trg_no_negative_stock
BEFORE UPDATE OF stock ON products
FOR EACH ROW
WHEN NEW.stock < 0
BEGIN
SELECT RAISE(ABORT, 'stock cannot go negative');
END;
UPDATE products SET stock = stock - 100 WHERE name = 'Gadget';
BEFORE runs the trigger before the row is written, so it can veto the
change. The WHEN NEW.stock < 0 clause limits the trigger to only the rows
where the incoming value would be invalid — most updates skip the trigger
body entirely. RAISE(ABORT, 'message') cancels the triggering statement
and rolls back the change with the given error message; Gadget's stock is
never touched, and the earlier trg_stock_audit trigger doesn't fire either
because the UPDATE never completes.
Cascading effects — deleting a parent cleans up children¶
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer TEXT NOT NULL
);
CREATE TABLE order_items (
id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL,
sku TEXT NOT NULL
);
INSERT INTO orders (customer) VALUES ('Nadia');
INSERT INTO order_items (order_id, sku) VALUES (1, 'A1'), (1, 'B2');
CREATE TRIGGER trg_cascade_delete_items
AFTER DELETE ON orders
FOR EACH ROW
BEGIN
DELETE FROM order_items WHERE order_id = OLD.id;
END;
DELETE FROM orders WHERE id = 1;
SELECT * FROM order_items;
Deleting the order automatically deleted its line items — OLD.id inside
an AFTER DELETE trigger still refers to the row that just disappeared,
since SQLite captures its values before removal. This is a manual
alternative to ON DELETE CASCADE foreign keys; either approach works, but
a trigger makes the cascade logic explicit and lets you add extra steps
(like writing to an audit table) beyond a plain cascade.
Trap: triggers can fire recursively¶
By default SQLite does not let a trigger's own statements re-fire the
same trigger (recursive_triggers is off by default), but a trigger on
table A that modifies table B, which has a trigger that modifies table A
again, can create an infinite loop across tables. Always trace what each
trigger writes to before adding another trigger on a table it touches.
Cheat sheet¶
| Concept | Syntax | Notes |
|---|---|---|
| Fire after a change | AFTER INSERT/UPDATE/DELETE ON table |
Row is already written; good for audit logs, cascades |
| Fire before a change | BEFORE INSERT/UPDATE/DELETE ON table |
Can inspect and reject via RAISE(ABORT, ...) |
| Scope to one column | UPDATE OF col ON table |
Fires only when that column is in the UPDATE |
| Old/new row values | OLD.col / NEW.col |
OLD unavailable on INSERT, NEW unavailable on DELETE |
| Conditional firing | WHEN condition |
Skips the body when the condition is false |
| Cancel + roll back | SELECT RAISE(ABORT, 'msg') |
Only meaningful in BEFORE triggers |
| Drop a trigger | DROP TRIGGER name; |
— |
How It Actually Works¶
A trigger is compiled into its own VDBE sub-program, attached to a specific
table event (BEFORE/AFTER × INSERT/UPDATE/DELETE), and stored (like
views) as text in sqlite_master. When the triggering statement executes,
after the row-level operation completes (for AFTER) or before it's applied
(for BEFORE), the VDBE jumps into the trigger's sub-program with the
special OLD and NEW pseudo-row values bound as local registers pointing
at the pre- and post-image of the affected row. Triggers fire per row,
not once per statement — an UPDATE touching 10,000 rows invokes the
trigger's sub-program 10,000 times, each with its own OLD/NEW bindings,
which is a common performance trap when a trigger itself does expensive work
like a lookup into another table. Triggers can cascade: a trigger's own
INSERT/UPDATE/DELETE can fire other triggers, and SQLite enforces a
recursion depth limit (SQLITE_LIMIT_TRIGGER_DEPTH) specifically to catch
runaway cascades, since there's no built-in cycle detection otherwise — a
poorly designed pair of triggers on two tables that each update the other
can trigger infinite recursion.
Exercise¶
Using the products/stock_log schema above:
- Write an
AFTER INSERTtrigger onproductsthat inserts astock_logrow withold_stock = NULLandnew_stockequal to the initial stock, so new products appear in the audit trail too. - Write a
BEFORE UPDATEtrigger that preventsnamefrom ever being set to an empty string, usingRAISE(ABORT, ...). - Explain, in your own words, why
BEFOREtriggers are the right choice for validation/rejection, whileAFTERtriggers are the right choice for audit logging and cascades.