Triggers and Automation
In many database systems, stored procedures let you save and reuse blocks of SQL logic. SQLite doesn't support stored procedures, but it has a powerful alternative: triggers. Triggers are blocks of SQL that run automatically when specific events happen in your database. They're perfect for enforcing rules, keeping audit logs, and automating repetitive work.
What You'll Learn
By the end of this lesson you will write a trigger that keeps a running total correct without anyone having to remember to update it, and you will be able to say in advance whether a given trigger has to be BEFORE or AFTER and what goes wrong if you pick the other one. That second half is the skill: a BEFORE INSERT trigger runs while the new row does not yet exist in the table, so a total it computes by querying that table is permanently one row behind — and nothing errors, the number is simply always stale.
This lesson is optional. The core path finished with the capstone; these last lessons are extensions for when a job needs them, and none of them is required to consider the track complete. You arrive able to insert and update rows (Inserting and Updating) and to wrap several writes in a transaction that can be rolled back (Transactions); what is new is SQL that the database runs on your behalf, without a caller asking for it.
What Are Triggers?
A trigger is a named piece of SQL that fires automatically in response to an INSERT, UPDATE, or DELETE on a specific table. You don't call triggers manually — they run on their own whenever the triggering event occurs.
AFTER INSERT Trigger
-- Create tables
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer TEXT NOT NULL,
product TEXT NOT NULL,
quantity INTEGER NOT NULL,
total REAL NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE order_log (
id INTEGER PRIMARY KEY,
order_id INTEGER,
message TEXT,
logged_at TEXT DEFAULT (datetime('now'))
);
-- Create a trigger that logs every new order
CREATE TRIGGER log_new_order
AFTER INSERT ON orders
BEGIN
INSERT INTO order_log (order_id, message)
VALUES (NEW.id, 'New order: ' || NEW.quantity || 'x ' || NEW.product || ' for ' || NEW.customer);
END;
-- Insert some orders (the trigger fires automatically)
INSERT INTO orders (id, customer, product, quantity, total)
VALUES (1, 'Alice', 'Laptop', 2, 1999.98);
INSERT INTO orders (id, customer, product, quantity, total)
VALUES (2, 'Bob', 'Mouse', 5, 124.95);
INSERT INTO orders (id, customer, product, quantity, total)
VALUES (3, 'Carol', 'Monitor', 1, 349.99);
-- Check the automatically generated log
SELECT id, order_id, message FROM order_log ORDER BY id;
Inside a trigger, NEW refers to the row being inserted (or the new values in an UPDATE). The trigger ran three times — once for each INSERT — without any manual intervention.
The created_at and logged_at columns default to datetime('now'), so the values you see there are whatever the clock read when you pressed Run. They are stored, not fixed: run it again tomorrow and they differ. Nothing else in this lesson compares them, and neither should any test you write — a timestamp is a record of when something happened, never a value to assert on.
BEFORE Triggers for Validation
BEFORE triggers run before the operation happens, letting you reject data before it is written.
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
stock INTEGER NOT NULL DEFAULT 0
);
-- Reject negative prices
CREATE TRIGGER validate_price
BEFORE INSERT ON products
WHEN NEW.price < 0
BEGIN
SELECT RAISE(ABORT, 'Price cannot be negative');
END;
-- Reject negative stock
CREATE TRIGGER validate_stock
BEFORE INSERT ON products
WHEN NEW.stock < 0
BEGIN
SELECT RAISE(ABORT, 'Stock cannot be negative');
END;
INSERT INTO products (id, name, price, stock) VALUES (1, 'Widget', 9.99, 100);
INSERT INTO products (id, name, price, stock) VALUES (2, 'Gadget', 19.99, 50);
SELECT id, name, price, stock FROM products ORDER BY id;
RAISE(ABORT, 'message') stops the operation and raises an error. This is how a trigger enforces a business rule at the database level: the rule lives in one place and applies to every caller, including one that forgot it existed.
Here is what the rejection actually looks like. The same two triggers, plus an insert that violates the first one:
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
stock INTEGER NOT NULL DEFAULT 0
);
CREATE TRIGGER validate_price
BEFORE INSERT ON products
WHEN NEW.price < 0
BEGIN
SELECT RAISE(ABORT, 'Price cannot be negative');
END;
INSERT INTO products (id, name, price, stock) VALUES (1, 'Widget', 9.99, 100);
INSERT INTO products (id, name, price, stock) VALUES (3, 'Bad Item', -5.00, 10);
SELECT id, name FROM products ORDER BY id;
The message in RAISE is the message the database returns, with nothing added to it:
Error: Price cannot be negative
Read the output pane as well as the error. Widget was inserted and the final SELECT still ran and still printed it — a failing statement aborts itself, not the rest of the script. Only Bad Item is missing.
ABORT is not the only way a trigger can refuse a row. RAISE(IGNORE) drops the row silently, and the difference between them is not in what ends up in the table.
Predict
A guard trigger rejects deposits of zero or less. Three deposits are attempted, the middle one for zero. Consider two versions of the trigger, one raising IGNORE and one raising ABORT with a message. Which statement describes how the two runs differ?
CREATE TABLE deposits (id INTEGER PRIMARY KEY, account TEXT NOT NULL, amount INTEGER NOT NULL);
CREATE TRIGGER g BEFORE INSERT ON deposits FOR EACH ROW WHEN NEW.amount <= 0
BEGIN SELECT RAISE(IGNORE); END;
INSERT INTO deposits VALUES (1,'alice',100);
INSERT INTO deposits VALUES (2,'bob',0);
INSERT INTO deposits VALUES (3,'carol',250);
SELECT id, account FROM deposits ORDER BY id;Predict
Two triggers on the same table, one BEFORE INSERT and one AFTER INSERT, and each writes a log row recording how many rows the table held at the moment it ran. One row is then inserted into an empty table. What do the two log rows report?
CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER NOT NULL);
CREATE TABLE log (seq INTEGER PRIMARY KEY, note TEXT NOT NULL, rows_then INTEGER NOT NULL);
CREATE TRIGGER a_before BEFORE INSERT ON t
BEGIN INSERT INTO log (note, rows_then) VALUES ('before', (SELECT COUNT(*) FROM t)); END;
CREATE TRIGGER b_after AFTER INSERT ON t
BEGIN INSERT INTO log (note, rows_then) VALUES ('after', (SELECT COUNT(*) FROM t)); END;
INSERT INTO t VALUES (1, 10);
SELECT note, rows_then FROM log ORDER BY seq;UPDATE Triggers for Audit Trails
Tracking changes is one of the most common uses for triggers. An audit trail records who changed what and when.
-- Create employees table
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
position TEXT NOT NULL,
salary INTEGER NOT NULL,
updated_at TEXT
);
-- Create audit trail table
CREATE TABLE salary_audit (
id INTEGER PRIMARY KEY,
employee_id INTEGER,
employee_name TEXT,
old_salary INTEGER,
new_salary INTEGER,
change_amount INTEGER,
changed_at TEXT DEFAULT (datetime('now'))
);
-- Trigger to track salary changes
CREATE TRIGGER track_salary_change
AFTER UPDATE OF salary ON employees
WHEN OLD.salary != NEW.salary
BEGIN
INSERT INTO salary_audit (employee_id, employee_name, old_salary, new_salary, change_amount)
VALUES (OLD.id, OLD.name, OLD.salary, NEW.salary, NEW.salary - OLD.salary);
UPDATE employees SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- Insert employees
INSERT INTO employees (id, name, position, salary)
VALUES
(1, 'Alice', 'Developer', 80000),
(2, 'Bob', 'Designer', 75000),
(3, 'Carol', 'Manager', 90000);
-- Give raises (triggers fire automatically)
UPDATE employees SET salary = 88000 WHERE name = 'Alice';
UPDATE employees SET salary = 78000 WHERE name = 'Bob';
UPDATE employees SET salary = 95000 WHERE name = 'Carol';
-- View the audit trail
SELECT id, employee_name, old_salary, new_salary, change_amount FROM salary_audit ORDER BY id;
-- View updated employees
SELECT id, name, position, salary FROM employees ORDER BY id;
Inside an UPDATE trigger, OLD refers to the row's values before the update and NEW refers to the values after. The WHEN clause ensures the trigger only fires when the salary actually changes.
Note what the audit trail is ordered by. changed_at is a datetime('now') default with one-second resolution, so all three rows here carry the same value and sorting by it decides nothing — the rows come back in whatever order the engine found them. Ordering by id gives a real sequence, because the ids are assigned in insertion order and are unique. Whenever a timestamp is your only sort key, check its resolution against how fast the rows arrive; a tiebreak that ties every time is not a tiebreak.
DELETE Triggers for Soft Deletes
Instead of permanently deleting records, you can use triggers to archive them.
-- Create active and archived customer tables
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
status TEXT DEFAULT 'active'
);
CREATE TABLE customers_archive (
id INTEGER,
name TEXT,
email TEXT,
original_status TEXT,
archived_at TEXT DEFAULT (datetime('now'))
);
-- Archive customers before deletion
CREATE TRIGGER archive_customer
BEFORE DELETE ON customers
BEGIN
INSERT INTO customers_archive (id, name, email, original_status)
VALUES (OLD.id, OLD.name, OLD.email, OLD.status);
END;
-- Insert customers
INSERT INTO customers (id, name, email)
VALUES
(1, 'Alice', 'alice@example.com'),
(2, 'Bob', 'bob@example.com'),
(3, 'Carol', 'carol@example.com');
-- Delete a customer (trigger archives automatically)
DELETE FROM customers WHERE name = 'Bob';
-- Bob is gone from the main table
SELECT id, name, email, status FROM customers ORDER BY id;
-- But preserved in the archive
SELECT id, name, email, original_status FROM customers_archive ORDER BY id;
This pattern is called a soft delete. The data isn't truly lost — it's moved to an archive table where it can be recovered if needed.
Triggers for Maintaining Computed Values
Triggers can automatically keep derived values up to date — and this is the use where the BEFORE versus AFTER choice stops being a detail and becomes the whole correctness of the feature.
Debug
A trigger keeps a cart's item count and total in step with the cart's line items, so no caller has to remember to update them. Three items go in, and the script prints the maintained totals beside the real ones computed directly from the line items. It runs, exits cleanly, and the two disagree: the maintained row says 3 items and 104997 cents while the real figures are 4 and 112996. Commit a hypothesis about which rows the trigger's subqueries can see when they run, then fix the trigger so the maintained totals match.
CREATE TABLE cart_items (id INTEGER PRIMARY KEY, cart_id INTEGER NOT NULL, price_cents INTEGER NOT NULL, quantity INTEGER NOT NULL);
CREATE TABLE cart_totals (cart_id INTEGER PRIMARY KEY, item_count INTEGER NOT NULL DEFAULT 0, total_cents INTEGER NOT NULL DEFAULT 0);
INSERT INTO cart_totals (cart_id) VALUES (1);
CREATE TRIGGER refresh_cart
BEFORE INSERT ON cart_items
BEGIN
UPDATE cart_totals
SET item_count = (SELECT COALESCE(SUM(quantity), 0) FROM cart_items WHERE cart_id = NEW.cart_id),
total_cents = (SELECT COALESCE(SUM(price_cents * quantity), 0) FROM cart_items WHERE cart_id = NEW.cart_id)
WHERE cart_id = NEW.cart_id;
END;
INSERT INTO cart_items VALUES (1, 1, 99999, 1);
INSERT INTO cart_items VALUES (2, 1, 2499, 2);
INSERT INTO cart_items VALUES (3, 1, 7999, 1);
SELECT cart_id, item_count, total_cents FROM cart_totals;
SELECT COALESCE(SUM(quantity),0) AS real_count, COALESCE(SUM(price_cents*quantity),0) AS real_total FROM cart_items WHERE cart_id = 1;Expected output:
1 row(s) affected 1 row(s) affected 1 row(s) affected 1 row(s) affected cart_id | item_count | total_cents ---------------------------------- 1 | 4 | 112996 real_count | real_total ----------------------- 4 | 112996
The corrected version of that pattern is below, with both an insert and a delete trigger so the totals survive a removal as well.
-- Create a shopping cart system
CREATE TABLE cart_items (
id INTEGER PRIMARY KEY,
cart_id INTEGER NOT NULL,
product_name TEXT NOT NULL,
price REAL NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE cart_totals (
cart_id INTEGER PRIMARY KEY,
item_count INTEGER DEFAULT 0,
total_amount REAL DEFAULT 0.0
);
-- Initialize a cart
INSERT INTO cart_totals (cart_id) VALUES (1);
-- Trigger: update totals when items are added
CREATE TRIGGER update_cart_on_insert
AFTER INSERT ON cart_items
BEGIN
UPDATE cart_totals
SET item_count = (SELECT SUM(quantity) FROM cart_items WHERE cart_id = NEW.cart_id),
total_amount = (SELECT SUM(price * quantity) FROM cart_items WHERE cart_id = NEW.cart_id)
WHERE cart_id = NEW.cart_id;
END;
-- Trigger: update totals when items are removed
CREATE TRIGGER update_cart_on_delete
AFTER DELETE ON cart_items
BEGIN
UPDATE cart_totals
SET item_count = COALESCE((SELECT SUM(quantity) FROM cart_items WHERE cart_id = OLD.cart_id), 0),
total_amount = COALESCE((SELECT SUM(price * quantity) FROM cart_items WHERE cart_id = OLD.cart_id), 0.0)
WHERE cart_id = OLD.cart_id;
END;
-- Add items to the cart
INSERT INTO cart_items (id, cart_id, product_name, price, quantity)
VALUES (1, 1, 'Laptop', 999.99, 1);
INSERT INTO cart_items (id, cart_id, product_name, price, quantity)
VALUES (2, 1, 'Mouse', 24.99, 2);
INSERT INTO cart_items (id, cart_id, product_name, price, quantity)
VALUES (3, 1, 'Keyboard', 79.99, 1);
-- Check cart totals (automatically maintained)
SELECT cart_id, item_count, total_amount FROM cart_totals ORDER BY cart_id;
-- Remove an item
DELETE FROM cart_items WHERE product_name = 'Mouse';
-- Totals update automatically
SELECT cart_id, item_count, total_amount FROM cart_totals ORDER BY cart_id;
SELECT id, cart_id, product_name, price, quantity FROM cart_items ORDER BY id;
Managing Triggers
A trigger is invisible at the call site: nothing in an INSERT says that something else will run. So knowing how to list what triggers a database actually has is part of using them safely.
Every SQLite database keeps a built-in table called sqlite_master that describes its own contents — one row per table, index, view and trigger, with a type column naming which it is, a name, the tbl_name it is attached to, and the sql text it was created from. It is an ordinary table you can query with ordinary SQL, and it is how you find out what is in a database you did not build.
-- Create a simple table and trigger
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
content TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TRIGGER note_created
AFTER INSERT ON notes
BEGIN
SELECT 1; -- No-op trigger for demonstration
END;
-- List all triggers in the database
SELECT name, tbl_name, sql
FROM sqlite_master
WHERE type = 'trigger';
-- Drop a trigger
DROP TRIGGER IF EXISTS note_created;
-- Verify it's gone
SELECT name FROM sqlite_master WHERE type = 'trigger';
-- Insert still works, just without the trigger
INSERT INTO notes (id, content) VALUES (1, 'Hello World');
SELECT id, content FROM notes ORDER BY id;
A trigger only covers writes made while it exists
DROP TRIGGER does not undo what the trigger already did, and creating one does not retroactively log the writes that happened before it. The audit trail is therefore a record of the window the trigger was alive for, and the order of those statements is what defines that window.
CREATE TABLE accounts (name TEXT PRIMARY KEY, balance INTEGER NOT NULL);
CREATE TABLE audit (id INTEGER PRIMARY KEY, note TEXT NOT NULL);
INSERT INTO accounts VALUES ('ada', 100);
Every arrangement below runs against those two tables, already seeded with Ada at 100.
Arrange the code
These five lines create an audit trigger, make two withdrawals from Ada's account, drop the trigger between them, and report her balance beside what the audit caught. They are shuffled. Put them in the order that logs the first withdrawal and not the second, and that ends with the balance at 50 — then answer what the order is really testing: what would the audit table hold if the DROP were one line earlier, and what if the CREATE were one line later?
UPDATE accounts SET balance = 70 WHERE name = 'ada';CREATE TRIGGER log_change AFTER UPDATE OF balance ON accounts BEGIN INSERT INTO audit (note) VALUES (OLD.balance || ' to ' || NEW.balance); END;SELECT balance, (SELECT COUNT(*) FROM audit) AS logged, (SELECT note FROM audit ORDER BY id LIMIT 1) AS first_note FROM accounts WHERE name = 'ada';UPDATE accounts SET balance = 50 WHERE name = 'ada';DROP TRIGGER log_change;
Try It Yourself
A support desk wants a ticket table that maintains its own summary: how many tickets are open, and how many have been closed. Nobody should have to remember to update that summary, so it is a trigger's job — and it needs two of them, because a ticket both arrives and gets closed.
Build
The starter has a tickets table, a one-row summary table and no triggers at all, so the summary stays at zero while four tickets arrive and two of them are closed. Work TODO 1 and TODO 2 so the summary maintains itself: after the script runs it must report 2 open and 2 closed, matching the counts the verification query computes directly from the tickets table.
CREATE TABLE tickets (id INTEGER PRIMARY KEY, subject TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open');
CREATE TABLE ticket_summary (id INTEGER PRIMARY KEY, open_count INTEGER NOT NULL DEFAULT 0, closed_count INTEGER NOT NULL DEFAULT 0);
INSERT INTO ticket_summary (id) VALUES (1);
-- TODO 1: add a trigger that runs when a ticket is inserted and recomputes both
-- counts in ticket_summary from the tickets table.
-- It must see the incoming row, so choose its timing accordingly.
-- TODO 2: add a second trigger that does the same recomputation after a ticket's
-- status is updated, so closing a ticket moves it from one count to the other.
-- Use UPDATE OF status so unrelated edits do not fire it.
-- Write both triggers above this line, then run.
CREATE VIEW open_tickets AS SELECT id, subject FROM tickets WHERE status = 'open';
INSERT INTO tickets (id, subject) VALUES (1, 'Login broken');
INSERT INTO tickets (id, subject) VALUES (2, 'Slow dashboard');
INSERT INTO tickets (id, subject) VALUES (3, 'Payment failing');
INSERT INTO tickets (id, subject) VALUES (4, 'Wrong currency');
UPDATE tickets SET status = 'closed' WHERE id = 2;
UPDATE tickets SET status = 'closed' WHERE id = 4;
SELECT open_count, closed_count FROM ticket_summary WHERE id = 1;
SELECT SUM(status = 'open') AS real_open, SUM(status = 'closed') AS real_closed FROM tickets;Expected output:
1 row(s) affected 1 row(s) affected 1 row(s) affected 1 row(s) affected 1 row(s) affected 1 row(s) affected 1 row(s) affected open_count | closed_count ------------------------- 2 | 2 real_open | real_closed ----------------------- 2 | 2
Once it passes, try two variations and predict each before running. First, change one trigger from AFTER to BEFORE and rerun — the summary goes out of step with the verification query by exactly one event's worth, with no error. Second, change AFTER UPDATE OF status to plain AFTER UPDATE and add UPDATE tickets SET subject = 'Login still broken' WHERE id = 1; before the final selects — the counts are unchanged, because a recomputation that runs more often still computes the same thing, which is precisely why this class of waste is hard to notice.
Recall
No notes. In 21-constraints-and-keys you enforced rules with CHECK constraints, and this lesson enforces rules with BEFORE triggers that call RAISE. Both reject bad rows. Which statement describes what a trigger can do that a CHECK constraint cannot?
The Same Total, Two Shapes
Transfer
Both shapes below report a cart's total. Shape A is the trigger pattern from this lesson: a real table that a trigger keeps in step. Shape B stores no total at all and defines a view that computes it on every read. Two carts exist; only cart 1 has anything in it. Run it and compare the two result blocks — what does each shape say about cart 2?
CREATE TABLE cart_items (id INTEGER PRIMARY KEY, cart_id INTEGER NOT NULL, price_cents INTEGER NOT NULL, quantity INTEGER NOT NULL);
-- Shape A: a stored total, maintained by an AFTER INSERT trigger
CREATE TABLE stored_totals (cart_id INTEGER PRIMARY KEY, total_cents INTEGER NOT NULL DEFAULT 0);
CREATE TRIGGER refresh AFTER INSERT ON cart_items
BEGIN
UPDATE stored_totals
SET total_cents = (SELECT COALESCE(SUM(price_cents * quantity), 0) FROM cart_items WHERE cart_id = NEW.cart_id)
WHERE cart_id = NEW.cart_id;
END;
-- Shape B: no stored total; a view computes it on every read
CREATE VIEW derived_totals AS
SELECT cart_id, COALESCE(SUM(price_cents * quantity), 0) AS total_cents
FROM cart_items GROUP BY cart_id;
INSERT INTO stored_totals (cart_id) VALUES (1), (2);
INSERT INTO cart_items VALUES (1, 1, 99999, 1);
INSERT INTO cart_items VALUES (2, 1, 2499, 2);
SELECT cart_id, total_cents FROM stored_totals ORDER BY cart_id;
SELECT cart_id, total_cents FROM derived_totals ORDER BY cart_id;Key Takeaways
- Triggers run automatically in response to INSERT, UPDATE, or DELETE events
- A BEFORE trigger runs while the row is not yet in the table, so it can validate
NEWbut must never recompute a total by aggregating that table; an AFTER trigger sees the row and is the right place for derived values - Use NEW to reference incoming data and OLD to reference existing data
RAISE(ABORT, 'message')cancels the statement and returns exactly that message asError: message; later statements in the script still run- A trigger covers only the writes made while it exists, so
CREATE TRIGGERandDROP TRIGGERdefine a window rather than a retroactive rule sqlite_masteris the built-in table describing a database's own tables, indexes, views and triggers — query it to find out what a database you did not build will do behind your back- Triggers are ideal for audit trails, validation, soft deletes, and maintaining computed values; prefer a CHECK constraint whenever the rule fits in one row, because it is visible in the table definition
Pro Tip: Don't overuse triggers. They add hidden behavior that can surprise other developers working with your database. Document your triggers well, and prefer application-level logic for complex business rules. Use triggers for simple, critical operations like audit logging and referential integrity.
Next Steps
Triggers handle automation at the row level. The last optional lesson takes them further: filtering execution with WHEN, choosing how a rule fails with the different RAISE resolutions, narrowing a trigger to specific columns with UPDATE OF, making read-only views writable with INSTEAD OF, and letting triggers fire each other recursively. Like this one, it is an extension rather than a requirement — the core path was already complete at the capstone.
Next lesson
Triggers and Events
Go beyond basic SQL triggers: WHEN filters, RAISE resolutions, UPDATE OF column lists, INSTEAD OF triggers on views, and recursive triggers.
25 min