A procedure has to be called. And all it takes is one application, one intern with psql or one import process writing straight into the table to bypass the whole thing. A trigger is the opposite: code that runs on its own, without anybody invoking it, at the exact moment a row is inserted, modified or deleted. It's the only piece in this course capable of guaranteeing that something always happens, no matter who does the writing.
This is where 09-02's promise gets kept: the business rules a CHECK can't express because they need to query another table. You'll see the full anatomy, the four axes that define a trigger's behaviour, the NEW and OLD variables with the mechanism that makes them useful —returning NULL cancels the operation—, the four canonical cases applied to GreenStore with complete code, and the INSTEAD OF triggers that make 10-01's views writable. And their dangers, given as much weight as the advantages, because a trigger is invisible logic: it makes an UPDATE do things you didn't write.
Contents
- Anatomy: trigger function +
CREATE TRIGGER - The four axes
NEW,OLD,TG_OPand the return value- Case 1: auditing price changes
- Case 2: maintaining a denormalized total
- Case 3: the rule a
CHECKcan't express - Case 4: automatic stock deduction, and whether it's worth it
INSTEAD OFon views- Firing order and management
- The dangers
- Common Mistakes and Tips
- Exercises
- Conclusion
- Anatomy: trigger function +
CREATE TRIGGER
CREATE TRIGGERIn PostgreSQL a trigger is two objects, and that separation is disconcerting at first:
- A function returning the special
TRIGGERtype that takes no declared parameters. - A
CREATE TRIGGERsaying on which table, when and at what granularity that function runs.
CREATE OR REPLACE FUNCTION fn_trg_example() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
RAISE NOTICE 'Operation % on table %', TG_OP, TG_TABLE_NAME;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_example
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION fn_trg_example();The advantage of splitting them is that one function can serve several triggers on several tables —hence TG_TABLE_NAME— and that's exactly what makes a generic audit function useful.
(Every trigger, function and table in this lesson is an example: none of them is part of the canonical GreenStore schema from 01-06. The orders.total column in case 2, in particular, is not canonical.)
- The four axes
| Axis | Values | What it means |
|---|---|---|
| When | BEFORE / AFTER / INSTEAD OF |
Before writing (can alter or cancel), after writing (the row already exists and has its id), or instead of the operation (views only) |
| What | INSERT, UPDATE, DELETE, TRUNCATE |
Combinable with OR. UPDATE OF price restricts it to that column |
| Granularity | FOR EACH ROW / FOR EACH STATEMENT |
Once per affected row, or once per statement (even if it touches a million rows or none) |
| Condition | WHEN (condition) |
A pre-filter: the trigger doesn't even fire if it isn't met |
The combination decides almost everything:
| You want to… | Use |
|---|---|
| Validate or reject a row | BEFORE ... FOR EACH ROW |
| Modify the value about to be stored | BEFORE ... FOR EACH ROW (returning an altered NEW) |
| Log, audit or propagate to another table | AFTER ... FOR EACH ROW |
| A summary or a global check after the statement | AFTER ... FOR EACH STATEMENT |
| Write through a view | INSTEAD OF ... FOR EACH ROW |
BEFORE versus AFTER in one sentence: in BEFORE the row doesn't exist yet, so you can change it or cancel it, but its auto-generated id isn't available yet in an INSERT; in AFTER it already exists and its id is real, but returning anything other than NULL changes nothing. Rule: validate and modify in BEFORE; react in AFTER.
And the WHEN isn't just elegance: WHEN (OLD.price IS DISTINCT FROM NEW.price) avoids running the function on the millions of UPDATEs that don't touch the price. It's the cheapest optimization a trigger has.
NEW, OLD, TG_OP and the return value
NEW, OLD, TG_OP and the return valueInside a FOR EACH ROW trigger function there are two row-typed variables, and you don't always have both:
| Operation | OLD |
NEW |
|---|---|---|
INSERT |
(doesn't exist) | The row about to be inserted |
UPDATE |
The row before the change | The row after the change |
DELETE |
The row about to be deleted | (doesn't exist) |
Using NEW in a DELETE or OLD in an INSERT gives record "new" is not assigned yet. Hence a function serving several operations has to ask with TG_OP ('INSERT', 'UPDATE', 'DELETE', 'TRUNCATE'). There are more variables available —TG_TABLE_NAME, TG_WHEN, TG_LEVEL, TG_ARGV[] with the CREATE TRIGGER's arguments— but those two solve nearly everything.
And now the mechanism that makes triggers useful: what the function's return value means.
| Context | RETURN NEW |
Modified RETURN NEW |
RETURN NULL |
|---|---|---|---|
BEFORE ... FOR EACH ROW |
Carries on normally | The altered row is stored | The operation is cancelled silently |
AFTER ... FOR EACH ROW |
Irrelevant | Irrelevant | Irrelevant |
FOR EACH STATEMENT |
Irrelevant | Irrelevant | Irrelevant |
Read that slowly, because these are the two capabilities no other tool in this course has: in a BEFORE ... FOR EACH ROW, returning NULL cancels the operation —the row isn't inserted, the UPDATE isn't applied, and the INSERT 0 0 is the only clue— and returning NEW with changed fields stores those changes. In a DELETE, what you return is OLD. In an AFTER, the return value is ignored and by convention you write RETURN NULL.
Cancelling silently is dangerous. If the row is being rejected because of a business rule, a
RAISE EXCEPTIONwith a clear message is almost always better thanRETURN NULL: whoever wrote theINSERTdeserves to know why nothing happened. Save theNULLfor deliberate, documented discards, such as filtering junk rows out of a bulk load.
- Case 1: auditing price changes
The most incontestable use of a trigger: recording what changed, who changed it and when. No application can forget to do it, because it doesn't depend on any application.
CREATE TABLE price_audit ( -- ⚠️ example table, NOT canonical
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
product_id INTEGER NOT NULL,
old_price NUMERIC(10,2),
new_price NUMERIC(10,2),
username TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL
);
CREATE OR REPLACE FUNCTION fn_audit_price() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO price_audit (product_id, old_price, new_price, username, changed_at)
VALUES (NEW.id, OLD.price, NEW.price, current_user, now());
RETURN NULL; -- AFTER: the return value is ignored
END;
$$;
CREATE TRIGGER trg_products_audit_price
AFTER UPDATE ON products
FOR EACH ROW
WHEN (OLD.price IS DISTINCT FROM NEW.price) -- only if the price really changes
EXECUTE FUNCTION fn_audit_price();UPDATE products SET price = 13.20 WHERE id = 1; -- the olive oil goes up
UPDATE products SET stock = 118 WHERE id = 1; -- doesn't touch the price
SELECT product_id, old_price, new_price, username FROM price_audit;| product_id | old_price | new_price | username |
|---|---|---|---|
| 1 | 12.50 | 13.20 | sql_course |
A single row: the second UPDATE fired nothing thanks to the WHEN. Three decisions worth copying from this example: AFTER, because you only audit what actually happened; IS DISTINCT FROM instead of <>, because with NULL involved <> isn't true and a change from NULL to a value would be lost (04-03); and TIMESTAMPTZ with now(), not whatever time the client sends.
- Case 2: maintaining a denormalized total
01-06 explained why orders does not have a total column: it's an aggregate you'd have to keep in sync. When computing it on the fly and 10-01's view are no longer enough —an order listing that sorts and filters by total over millions of rows— the way out is to store it and maintain it with a trigger.
ALTER TABLE orders ADD COLUMN total NUMERIC(10,2) NOT NULL DEFAULT 0; -- ⚠️ NOT canonical
CREATE OR REPLACE FUNCTION fn_recalc_order_total() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE
v_order_id INTEGER := COALESCE(NEW.order_id, OLD.order_id);
BEGIN
UPDATE orders AS o
SET total = COALESCE((SELECT ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2)
FROM order_lines AS ol WHERE ol.order_id = v_order_id), 0)
WHERE o.id = v_order_id;
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_lines_total
AFTER INSERT OR UPDATE OR DELETE ON order_lines
FOR EACH ROW EXECUTE FUNCTION fn_recalc_order_total();
-- Initial backfill: the trigger only covers the future
UPDATE orders AS o
SET total = COALESCE((SELECT ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2)
FROM order_lines AS ol WHERE ol.order_id = o.id), 0);| id | total |
|---|---|
| 1 | 42.10 |
| 2 | 26.70 |
| 3 | 29.53 |
| 4 | 31.75 |
| 5 | 32.10 |
| 6 | 26.75 |
(First 6 of 20 orders; SELECT SUM(total) FROM orders gives 727.95.) And if you now delete a line from order 1, its total recomputes on its own.
Three details that make it work and that always get forgotten: COALESCE(NEW.order_id, OLD.order_id), because in a DELETE there's no NEW; the outer COALESCE(..., 0), because when you delete the last line the SUM returns NULL and the column is NOT NULL; and the initial backfill, because a newly created trigger knows nothing about the past — that's the number-one mistake when denormalizing.
And the honest warning: this is what you do when there's no other way. You've traded a cheap read for an extra write on every INSERT, UPDATE and DELETE of lines, you've created a path for the data to drift out of sync and you've moved a rule somewhere invisible. Before you get here, try a view (10-01), an index (module 8) and a materialized view.
- Case 3: the rule a
CHECK can't express
CHECK can't expressThis is 09-02's promise. The rule is "a review can only be written by a customer who has bought that product", and a CHECK can't express it: a CHECK only sees the columns of its own row, and this requires querying orders and order_lines.
CREATE OR REPLACE FUNCTION fn_validate_review() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
WHERE o.customer_id = NEW.customer_id
AND ol.product_id = NEW.product_id
AND o.status <> 'cancelled'
) THEN
RAISE EXCEPTION 'Customer % has not bought product %', NEW.customer_id, NEW.product_id
USING ERRCODE = 'P0001',
HINT = 'Only customers who have bought the product can review it';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_reviews_validate
BEFORE INSERT OR UPDATE ON reviews
FOR EACH ROW EXECUTE FUNCTION fn_validate_review();-- Hugo (customer 14) has never placed an order
INSERT INTO reviews (product_id, customer_id, rating, comment, date)
VALUES (1, 14, 5, 'Great', '2026-03-01');ERROR: Customer 14 has not bought product 1 HINT: Only customers who have bought the product can review it CONTEXT: PL/pgSQL function fn_validate_review() line 12 at RAISE
And the 12 existing reviews are still valid: they all belong to customers who bought that product, so the trigger breaks nothing. Compare it with exercise 3b in 01-06, where this exact INSERT worked because no constraint prevented it. Not any more.
Two essential warnings. The first: a trigger validates what happens from now on, not what's already there. When adding a rule to a table with data in it, check first how many rows break it. The second is subtler: this check isn't immune to concurrency. Between the EXISTS's SELECT and the review's INSERT, another transaction could cancel the order; under READ COMMITTED the trigger wouldn't see it (09-04). For most business rules that's acceptable; for an invariant that must hold always, you need explicit locks or SERIALIZABLE.
- Case 4: automatic stock deduction, and whether it's worth it
Technically it's the simplest of the four:
CREATE OR REPLACE FUNCTION fn_reduce_stock() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE products SET stock = stock - NEW.quantity WHERE id = NEW.product_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE products SET stock = stock + OLD.quantity WHERE id = OLD.product_id;
ELSE -- UPDATE: give back the old quantity and deduct the new one
UPDATE products SET stock = stock + OLD.quantity WHERE id = OLD.product_id;
UPDATE products SET stock = stock - NEW.quantity WHERE id = NEW.product_id;
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_lines_stock
AFTER INSERT OR UPDATE OR DELETE ON order_lines
FOR EACH ROW EXECUTE FUNCTION fn_reduce_stock();It works, and products' CHECK (stock >= 0) stops it going negative: if somebody tries to sell more than there is, the trigger's UPDATE fails and the whole transaction is rolled back, including the line that caused it.
But is it worth it? There's no single answer here, and it's worth setting it against 10-04's sp_confirm_order:
| Trigger (here) | Procedure (10-04) | |
|---|---|---|
| Can it be bypassed? | No, whoever writes to order_lines |
Yes: just do the INSERT by hand |
| Error message | Generic: violates check constraint "products_stock_check" |
Clear: "Not enough stock of X: 0 left and 1 requested" |
| Advance check | There isn't one: you find out when the CHECK fails |
Yes, with FOR UPDATE before deciding |
| Visibility | Invisible to whoever reads the application code | Explicit |
| Bulk load of 100,000 lines | One extra UPDATE per line |
Can be optimized in bulk |
| Consistency if somebody fixes a historical record | Recomputes stock, perhaps unintentionally | Nothing is touched |
The criterion: if stock is a sacred invariant and there are several write paths, trigger. If there's a single controlled entry point and the message to the user matters, procedure. And if you pick the trigger, add a BEFORE that validates with a decent message instead of letting the CHECK raise the error.
INSTEAD OF on views
INSTEAD OF on views10-01 left open what to do with views that are not automatically updatable —the ones with JOINs, aggregates or computed columns. The answer is an INSTEAD OF trigger, which replaces the operation with whatever you decide.
CREATE OR REPLACE FUNCTION fn_v_detail_insert() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO order_lines (order_id, product_id, quantity, unit_price, discount)
VALUES (NEW.order_id, NEW.product_id, NEW.quantity,
COALESCE(NEW.unit_price, (SELECT price FROM products WHERE id = NEW.product_id)),
COALESCE(NEW.discount, 0));
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_v_detail_insert
INSTEAD OF INSERT ON v_sales_detail
FOR EACH ROW EXECUTE FUNCTION fn_v_detail_insert();Now INSERT INTO v_sales_detail (order_id, product_id, quantity) VALUES (1, 3, 2); works: the view joins four tables, but the trigger knows that only order_lines needs writing to and where to get the price from. Three rules: INSTEAD OF triggers only exist on views, they're always FOR EACH ROW, and you need three separate triggers —INSERT, UPDATE, DELETE— if you want the view to be fully writable. There's also the rule system (CREATE RULE), older and discouraged today: it rewrites the query before running it, with surprising effects when volatile functions are involved.
- Firing order and management
When several triggers compete for the same moment on the same table, PostgreSQL runs them in alphabetical order by name.
flowchart LR
A["UPDATE products<br/>SET price = 13.20"] --> B["<b>BEFORE</b> ROW<br/>alphabetical"] --> C["the row<br/>is written"]
C --> D["<b>AFTER</b> ROW<br/>alphabetical"] --> E["<b>AFTER</b> STATEMENT"]
B -.->|"RETURN NULL"| F["cancelled"]
Having the order depend on the name is as fragile as it sounds: renaming trg_validate to trg_zvalidate can change the system's behaviour without anyone touching a line of logic. If two triggers depend on the order between them, merge them into one; if they don't, so much the better.
| Task | How |
|---|---|
| List a table's triggers | \d products in psql, or SELECT tgname, tgenabled FROM pg_trigger WHERE tgrelid = 'products'::regclass AND NOT tgisinternal; |
| See their definition | SELECT pg_get_triggerdef(oid) FROM pg_trigger WHERE tgname = 'trg_lines_stock'; |
| Disable / re-enable | ALTER TABLE order_lines DISABLE TRIGGER trg_lines_stock; … ENABLE TRIGGER |
| Disable them all | ALTER TABLE order_lines DISABLE TRIGGER ALL; (requires superuser for the internal ones) |
| Drop | DROP TRIGGER trg_lines_stock ON order_lines; |
DISABLE TRIGGER is essential for a bulk load: importing a million lines with the stock trigger enabled means a million extra UPDATEs. You disable it, load, recompute the aggregates in one go and re-enable it. And it comes with a trap: while it's disabled, ordinary writes bypass it too, so do it in a controlled window and don't forget to switch it back on.
Name your triggers with a prefix and a pattern: trg_<table>_<what it does>. In a database with two hundred objects, finding out why an UPDATE does something odd starts with being able to list that table's triggers and understand their names.
- The dangers
They deserve as much space as the advantages, because a badly placed trigger is one of the hardest things there is to debug.
- Invisible logic. "The
UPDATEdid something I didn't write" is the sentence that defines the problem. The application's SQL doesn't mention the trigger anywhere; whoever is debugging has to suspect it exists. It's why many teams limit them to auditing and integrity, which are predictable uses. - Cost per row.
FOR EACH ROWmeans one execution per row. AnUPDATEtouching 500,000 rows runs the function 500,000 times, with its queries inside. What takes 0.2 ms on one row takes 100 seconds on half a million. - Cascades and recursion. A trigger on
order_linesthat updatesorderscan fire a trigger onordersthat updatesorder_lines... and so on untilstack depth limit exceeded. A trigger that modifies its own table is directly recursive; you cut it off with aWHENthat detects there's nothing to do, or withpg_trigger_depth() = 0. - Hard to test. They can't be invoked in isolation: you have to provoke the operation that fires them, and the tests end up being integration tests.
- Transactional, for better and for worse. A trigger runs inside the transaction that fires it: if it fails, everything is rolled back, including the original operation. That's what you want for integrity; it's a disaster if inside it you call an external service or send an email (10-04, exercise 3).
- Interaction with
COPYand with FKs.COPYfires row triggers, so an import can be far slower than expected. And PostgreSQL's foreign keys are implemented as internal triggers, which don't show up in\dbut do inpg_triggerwithtgisinternal = true.
| A trigger is the right answer when… | It's a patch when… |
|---|---|
| The rule must hold no matter who writes | There's only one write path: put it there and it's visible |
| Changes have to be audited incontestably | It replaces a CHECK or an FK that could express it |
A CHECK can't, because it queries another table |
It chains cascading effects that are hard to follow |
| It maintains derived data you've already decided to denormalize | It's used to patch data the application sends wrong |
You need INSTEAD OF on a view |
It contains complex business logic: that's a procedure |
Dialect note: the concept is universal, the syntax isn't. PostgreSQL separates function and trigger, and it's the only one on this list that does. MySQL 8 puts the body inside the
CREATE TRIGGER, only supportsFOR EACH ROWand has noINSTEAD OFand no statement triggers. SQL Server works per statement with theINSERTEDandDELETEDpseudo-tables instead ofNEW/OLD, and does haveINSTEAD OF. Oracle uses:NEWand:OLDwith colons and adds compound triggers. SQLite has them, withFOR EACH ROWmandatory and noTRUNCATE.
Common Mistakes and Tips
- Forgetting the
RETURNin aBEFORE ... FOR EACH ROW. Falling off the end of the function returnsNULL, and that cancels the operation silently: rows that don't get inserted with no error at all. - Using
NEWin aDELETEorOLDin anINSERT.record "new" is not assigned yet. AskTG_OPor useCOALESCE(NEW.x, OLD.x). - Creating a validation trigger and not reviewing the existing data. It only applies to the future; the rows that already break the rule stay.
- Denormalizing without the initial backfill. The trigger maintains the column from now on, but the previous 20 rows stay at 0.
- Comparing with
<>instead ofIS DISTINCT FROMin theWHEN. WithNULLinvolved,<>isn't true and the change doesn't get audited. - Putting a
FOR EACH ROWwhere aFOR EACH STATEMENTwould do, or putting an expensive query inside a row trigger. It multiplies by the number of rows. - Making external calls from a trigger. It runs inside the transaction: if that's rolled back, the email has already gone out and can't be unsent.
- Depending on alphabetical order between two triggers. It's fragile. If the order matters, merge them.
- Leaving
DISABLE TRIGGER ALLswitched on. The bulk load finishes and nobody re-enables it; from then on the rules don't apply and nobody finds out until months later. - Tip: prefix
trg_and patterntrg_<table>_<action>. And document it alongside theCREATE TABLE, withCOMMENT ON TRIGGER, saying what it does and why. - Tip:
RAISE EXCEPTIONwith a clear message, notRETURN NULL. Silence is the worst possible error message. - Tip: faced with inexplicable behaviour,
\d tableis the first command. Triggers are listed right there, and very often the explanation is in that line.
Exercises
Exercise 1
Design a complete audit trail for products: a product_audit table recording any INSERT, UPDATE or DELETE with the operation, the old row and the new one in text form, the user and the moment. (1) BEFORE or AFTER? ROW or STATEMENT? (2) Write the function and the trigger using TG_OP. (3) How would you make it generic so it also serves customers and orders without duplicating code?
Exercise 2
On section 6's review trigger, reason out your answers and then check them: (1) What happens if you try to insert Hugo's review (customer 14) on product 1? And if customer 1 reviews product 1? (2) Why is the trigger BEFORE and not AFTER? Would it work the same as an AFTER? (3) Order 6 is cancelled: if its customer tried to review a product they only bought in that order, would it pass validation? Find the line in the query responsible.
Exercise 3
A colleague has put this trigger on products and now no UPDATE ever finishes:
CREATE OR REPLACE FUNCTION fn_mark_reviewed() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
UPDATE products SET added_date = CURRENT_DATE WHERE id = NEW.id;
RETURN NEW;
END; $$;
CREATE TRIGGER trg_products_reviewed AFTER UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION fn_mark_reviewed();(1) What error does it give and why? (2) Rewrite it properly, with no inner UPDATE. (3) In what other case, besides this one, can a trigger end up in a loop?
Solutions
Solution 1
1. AFTER ... FOR EACH ROW. AFTER because you only audit what really happened —in BEFORE, the operation could still fail on a CHECK or an FK and you'd be left with a record of something that never occurred— and FOR EACH ROW because you audit each row, not each statement. 2:
CREATE TABLE product_audit ( -- ⚠️ example table, NOT canonical
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
table_name TEXT, operation TEXT, row_id INTEGER,
old_value TEXT, new_value TEXT, username TEXT, changed_at TIMESTAMPTZ
);
CREATE OR REPLACE FUNCTION fn_audit_generic() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO product_audit (table_name, operation, row_id, old_value, new_value, username, changed_at)
VALUES (TG_TABLE_NAME, TG_OP,
COALESCE(NEW.id, OLD.id),
CASE WHEN TG_OP <> 'INSERT' THEN OLD::TEXT END,
CASE WHEN TG_OP <> 'DELETE' THEN NEW::TEXT END,
current_user, now());
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_products_audit AFTER INSERT OR UPDATE OR DELETE ON products
FOR EACH ROW EXECUTE FUNCTION fn_audit_generic();3. It already is. The function doesn't mention products anywhere: it uses TG_TABLE_NAME to know where it came from and NEW::TEXT / OLD::TEXT to dump the whole row whatever its structure. All you need is another identical CREATE TRIGGER on customers and orders. The two CASEs are necessary because OLD doesn't exist in an INSERT nor NEW in a DELETE. In a real system you'd store to_jsonb(NEW) in a JSONB column instead of text, and then the audit trail would be queryable key by key — which is 10-06.
Solution 2
1. Hugo's fails with ERROR: Customer 14 has not bought product 1: he has no orders at all. Customer 1's on product 1 passes, because the olive oil is on line 1 of their order 1. In fact that review already exists: it's number 1 of the twelve.
2. It's BEFORE because it validates, and validating means rejecting before writing. As an AFTER it would also work —the RAISE EXCEPTION would abort the transaction and the inserted row would be rolled back— but pointless work would have been done: writing the row, updating its indexes and undoing it all. BEFORE to validate, AFTER to react, and here BEFORE also leaves the door open to correcting NEW instead of rejecting it.
3. It wouldn't pass, because of the line AND o.status <> 'cancelled'. It's a deliberate business decision: a cancelled order isn't a purchase, so it doesn't earn the right to review. And it's exactly the kind of nuance that only fits in a trigger: a CHECK can't query orders.status, and neither can a foreign key.
Solution 3
1. It gives ERROR: stack depth limit exceeded. The trigger fires AFTER UPDATE on products and does an UPDATE on products, which fires it again, which updates again… until the stack runs out. It's section 10's direct recursion, and there's no automatic protection against it.
2. The right way is not to update anything: modifying the row being written is done in a BEFORE, by changing NEW and returning it.
CREATE OR REPLACE FUNCTION fn_mark_reviewed() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.added_date := CURRENT_DATE; -- stored with the row itself: no UPDATE, no recursion
RETURN NEW;
END; $$;
CREATE TRIGGER trg_products_reviewed BEFORE UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION fn_mark_reviewed();This is the canonical pattern for the updated_at column that almost every production table carries, and the reason it's implemented with BEFORE and not with AFTER.
3. With cascades across tables: A writes to B, a trigger on B writes to A, and the cycle closes without either one modifying itself. Also with ON DELETE CASCADE combined with delete triggers, and with two triggers that keep reactivating each other on the same table. The defences: a WHEN that cuts things off once there's nothing left to change (WHEN (OLD.total IS DISTINCT FROM NEW.total)), the check IF pg_trigger_depth() > 1 THEN RETURN NULL; END IF;, or redesigning so the cycle doesn't exist.
Conclusion
Code that runs on its own, with everything that implies:
- A trigger is two objects: a function returning
TRIGGERand aCREATE TRIGGERsaying on which table, when and at what granularity it runs. The separation lets a generic function serve several tables, withTG_TABLE_NAMEandTG_OP. - The four axes: when (
BEFOREvalidates and modifies,AFTERreacts,INSTEAD OFreplaces), what (INSERT/UPDATE/DELETE/TRUNCATE), granularity (FOR EACH ROWversusFOR EACH STATEMENT) andWHEN (condition), which avoids firing for nothing. NEWandOLDaren't always both there, and the return value is the key mechanism: in aBEFORE ... FOR EACH ROW,RETURN NULLcancels the operation and a modifiedRETURN NEWalters it. InAFTERit's ignored.- The four GreenStore cases: auditing price changes with
WHEN (OLD.price IS DISTINCT FROM NEW.price)—the most incontestable use—; a denormalizedorders.totalthat adds up to €727.95 and demands an initial backfill; the review rule aCHECKcan't express because it queries another table, closing 09-02; and stock deduction, with the honest comparison against 10-04'ssp_confirm_order. INSTEAD OFtriggers make writable the views 10-01 couldn't update automatically: views only, alwaysFOR EACH ROW, and one per operation.- The firing order is alphabetical by name, which is fragile: if two triggers depend on the order, merge them. You list them with
\dand withpg_trigger, switch them off withALTER TABLE ... DISABLE TRIGGERfor bulk loads and name themtrg_<table>_<action>. - And the dangers, given equal weight: invisible logic that catches out whoever is debugging, cost per row in bulk loads, cascades and recursion up to
stack depth limit exceeded, difficulty testing, and the fact that they run inside the transaction that fires them. A trigger is the right answer when the rule must hold no matter who writes; it's a patch when it replaces aCHECKor hides business logic that ought to be visible.
With views, CTEs, window functions, procedures and triggers, the relational SQL toolbox is nearly complete. One case is missing that the relational model handles badly by design: data whose shape isn't fixed. An olive oil has acidity and variety; a cream, ingredients and skin type; next year somebody will want to store each product's carbon footprint. Adding a column per attribute is unworkable, and building a key-value pair table is the classic, painful remedy. In the module's last lesson, JSON and semi-structured data, you'll see PostgreSQL's JSONB type: how to build documents, how to reach into them with ->, ->> and JSONPath, how to index them with GIN —08-03's outstanding promise—, how to turn them back into rows so you can keep using all the SQL in this course, and —most importantly— what should never go in a JSON.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
