Triggers: Run Database Logic When Data Changes
A trigger is database logic invoked automatically by a specified event. Unlike a procedure, which a caller explicitly invokes, a trigger can run because an application inserts, updates, or deletes rows.
This note uses SQLite and the fresh bookstore setup. We will record changes to product prices, then see what happens when the surrounding transaction rolls back.
The product rows used by these examples are:
| product_id | title | price_cents | stock |
|---|---|---|---|
| 10 | Database Basics | 1500 | 5 |
| 20 | SQL Practice | 2500 | 8 |
| 30 | History of Computing | 1800 | 0 |
Begin with a reason for the extra behavior
Suppose staff can update prices through two different applications. Both should record the old and new price. If each application must remember an extra insert, one might forget. A database trigger can apply that behavior to updates from either application.
First create a table to hold the history:
CREATE TABLE product_price_changes (
change_id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL,
old_price_cents INTEGER NOT NULL,
new_price_cents INTEGER NOT NULL,
changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
This example keeps the product identifier as historical information without a foreign key that would force a policy for later product deletion. It records price changes, not the identity of the human who caused them. Those are deliberate limits of this small example.
Define an update trigger
CREATE TRIGGER record_product_price_change
AFTER UPDATE OF price_cents ON products
FOR EACH ROW
WHEN OLD.price_cents <> NEW.price_cents
BEGIN
INSERT INTO product_price_changes
(product_id, old_price_cents, new_price_cents)
VALUES (NEW.product_id, OLD.price_cents, NEW.price_cents);
END;
Read the definition from top to bottom:
AFTERspecifies timing relative to the row update. It does not mean after transaction commit.UPDATE OF price_centsselects updates that mention this column.FOR EACH ROWmakes the body run separately for each qualifying row.OLDcontains the row's previous values;NEWcontains its new values.WHENexcludes assignments that leave the price unchanged.BEGIN ... ENDgroups the trigger's statements; it does not start an independent transaction.
Both prices are non-null in the bookstore schema, so <> is sufficient here. If nullable fields were allowed, the comparison would need a deliberate null policy. SQLite documents its trigger behavior and restrictions in CREATE TRIGGER.
Observe the extra write
UPDATE products SET price_cents = 1600 WHERE product_id = 10;
SELECT product_id, old_price_cents, new_price_cents
FROM product_price_changes
ORDER BY change_id;
| product_id | old_price_cents | new_price_cents |
|---|---|---|
| 10 | 1500 | 1600 |
The application issued an update to products; the trigger also inserted a history row. Repeating the same assignment does not create another history entry:
UPDATE products SET price_cents = 1600 WHERE product_id = 10;
SELECT COUNT(*) AS change_count FROM product_price_changes;
The count result is:
| change_count |
|---|
| 1 |
It stays 1 because the WHEN condition is false.
Trigger changes participate in rollback
Continue from the previous exercise, where the current price is 1600:
BEGIN;
UPDATE products SET price_cents = 1700 WHERE product_id = 10;
SELECT COUNT(*) AS change_count FROM product_price_changes;
ROLLBACK;
SELECT COUNT(*) AS change_count FROM product_price_changes;
SELECT price_cents FROM products WHERE product_id = 10;
The first count query returns:
| change_count |
|---|
| 2 |
After rollback, the count query returns 1 and the price query returns 1600:
| change_count |
|---|
| 1 |
| price_cents |
|---|
| 1600 |
The trigger's inserted row rolls back with the price update.
Consequently, this history table records accepted changes, not every attempted change or rejected statement. A security audit of unsuccessful attempts needs a different logging design.
Timing and scope vary by engine
| Choice | Meaning |
|---|---|
BEFORE |
Run before the affected row or statement is applied, where supported |
AFTER |
Run after the relevant change, generally still within its transaction |
INSTEAD OF |
Replace the requested operation, commonly for a view |
| Row trigger | Run for each affected row |
| Statement trigger | Run for the statement as a whole, where supported |
SQLite provides row triggers; other engines have different combinations and syntax. Do not paste a MySQL SET NEW.column = ... body into SQLite, or assume PostgreSQL trigger-function syntax works in every engine.
Prefer the simplest rule mechanism
Use NOT NULL, UNIQUE, CHECK, or a foreign key when they express the rule directly. Use a column default for a straightforward initial timestamp. A trigger is useful when a data change genuinely requires additional database work.
The tradeoff is less visible behavior. An update may write several tables, acquire additional locks, or activate further triggers. Review multi-row updates, rollback behavior, and interactions with other triggers. Keep trigger code short and document the behavior beside the schema.
Validate a transition when the old row matters
A row check can require an allowed status, but it cannot ordinarily express “a completed order must not return to open” using both the old and new versions. A SQLite trigger can reject that specific transition:
CREATE TRIGGER prevent_completed_order_reopening
BEFORE UPDATE OF status ON orders
FOR EACH ROW
WHEN OLD.status = 'completed' AND NEW.status = 'open'
BEGIN
SELECT RAISE(ABORT, 'completed orders cannot be reopened');
END;
Run this deliberately failing statement separately:
Input (orders):
| order_id | status |
|---|---|
| 101 | completed |
-- Expected trigger error; not part of a successful script
UPDATE orders SET status = 'open' WHERE order_id = 101;
The input status and status after the rejected update are:
| order_id | status |
|---|---|
| 101 | completed |
RAISE(ABORT, ...) aborts the statement; it does not mean that every earlier statement in an already open transaction has been rolled back. Application error handling still owns that larger boundary.
This rule prohibits one transition. It does not fully specify a state machine, and it does not validate refunds. If cancelled orders may never reopen either, declare that additional rule. Prefer a check for rules about one new row alone, and a trigger only when the old row or related work is essential.
Supply writes through a SQLite view explicitly
SQLite views do not automatically accept writes. An INSTEAD OF trigger can give a view a narrow update interface:
CREATE VIEW trigger_product_prices AS
SELECT product_id, title, price_cents FROM products;
CREATE TRIGGER update_price_through_view
INSTEAD OF UPDATE OF price_cents ON trigger_product_prices
FOR EACH ROW
BEGIN
UPDATE products
SET price_cents = NEW.price_cents
WHERE product_id = OLD.product_id;
END;
BEGIN;
UPDATE trigger_product_prices SET price_cents = 1800 WHERE product_id = 10;
SELECT product_id, price_cents FROM products WHERE product_id = 10;
SELECT COUNT(*) AS change_count FROM product_price_changes;
ROLLBACK;
Continuing the earlier price-history exercise, the first query returns:
| product_id | price_cents |
|---|---|
| 10 | 1800 |
The second query returns:
| change_count |
|---|
| 2 |
Updating the base table activates its existing audit trigger as well. The rollback returns the price to 1600 and count to 1. The view trigger chooses the old product ID as the target and only writes price; it is not a general-purpose implementation for every possible update to this view.
These nested effects are why readers of schema code need to know which triggers exist. A single visible update may touch several objects, and an error in the additional work can reject the original statement.
Modify a new row using PostgreSQL's trigger function
PostgreSQL defines the trigger's behavior in a function returning trigger. Unlike SQLite's syntax, a PostgreSQL before-row function can assign fields in NEW:
-- PostgreSQL; independent setup
CREATE TABLE trigger_pg_notes (
note_id INTEGER PRIMARY KEY,
body TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE FUNCTION trigger_pg_stamp_note()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := statement_timestamp();
RETURN NEW;
END;
$$;
CREATE TRIGGER stamp_note_update
BEFORE UPDATE ON trigger_pg_notes
FOR EACH ROW EXECUTE FUNCTION trigger_pg_stamp_note();
INSERT INTO trigger_pg_notes (note_id, body) VALUES (1, 'first draft');
UPDATE trigger_pg_notes SET body = 'revised draft' WHERE note_id = 1;
SELECT note_id, body, updated_at FROM trigger_pg_notes;
Initial input row:
| note_id | body |
|---|---|
| 1 | first draft |
Result (timestamp varies with execution time):
| note_id | body | updated_at |
|---|---|---|
| 1 | revised draft | statement timestamp |
The exact timestamp depends on execution time. RETURN NEW supplies the row version to write. CURRENT_TIMESTAMP in PostgreSQL represents the transaction's start time; using statement_timestamp() here deliberately records the statement's start instead. Neither timestamp is automatically a precise commit time.
A default handles the initial insert, while a trigger handles later updates. MySQL permits assignments with its own SET NEW.column = ... syntax in a before trigger. The same requirement can therefore have different executable forms across engines.
Handle all affected rows in SQL Server
SQL Server DML triggers operate per statement. Its inserted and deleted tables can contain multiple rows. A trigger that picks one scalar value from them can miss changes during a bulk update. This independent example records every changed price with a set-based insert:
-- SQL Server; use SSMS or sqlcmd for GO
CREATE TABLE dbo.TriggerProducts (
ProductId INT PRIMARY KEY,
PriceCents INT NOT NULL
);
CREATE TABLE dbo.TriggerPriceHistory (
ProductId INT NOT NULL,
OldPriceCents INT NOT NULL,
NewPriceCents INT NOT NULL
);
INSERT INTO dbo.TriggerProducts VALUES (10, 1500), (20, 2500);
GO
CREATE TRIGGER dbo.RecordTriggerPrices
ON dbo.TriggerProducts
AFTER UPDATE
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.TriggerPriceHistory (ProductId, OldPriceCents, NewPriceCents)
SELECT i.ProductId, d.PriceCents, i.PriceCents
FROM inserted AS i
JOIN deleted AS d ON d.ProductId = i.ProductId
WHERE i.PriceCents <> d.PriceCents;
END;
GO
UPDATE dbo.TriggerProducts SET PriceCents = PriceCents + 100;
SELECT ProductId, OldPriceCents, NewPriceCents
FROM dbo.TriggerPriceHistory ORDER BY ProductId;
GO
Input (dbo.TriggerProducts before the update):
| ProductId | PriceCents |
|---|---|
| 10 | 1500 |
| 20 | 2500 |
Result:
| ProductId | OldPriceCents | NewPriceCents |
|---|---|---|
| 10 | 1500 | 1600 |
| 20 | 2500 | 2600 |
The join assumes product IDs are stable during the update. A mutable primary key requires a different matching policy. The trigger handles the statement's full set without a cursor.
Manage trigger lifecycle and cost
SQLite changes an existing trigger by dropping and recreating it. PostgreSQL and SQL Server provide different replace or alter mechanisms. For the SQLite practice rule, cleanup is:
-- SQLite
DROP TRIGGER prevent_completed_order_reopening;
This removes the transition check, not the orders. Version that definition with the schema so every environment has the same behavior. Avoid assuming the firing order of several triggers supplies a reliable workflow unless the engine explicitly supports and defines that order.
Triggers add work to the original statement. Audit inserts need space, indexes require maintenance, and cross-table updates acquire more locks. Recursive trigger behavior is also configurable or restricted by the engine. Test a multi-row change, a no-op update, a failure in the trigger body, and outer rollback. Long network calls and irreversible external effects are poor trigger responsibilities; recording an outbox row keeps the database-side intent transactional.
References: PostgreSQL trigger functions and SQL Server multirow triggers.
Check your understanding
- Why does
AFTERnot mean “after commit”? - What do
OLDandNEWrefer to during a price update? - Why does assigning the same price twice produce only one history row?
- Does this table preserve a price change that was rolled back?
- When would a default or constraint be clearer than a trigger?
Next: hierarchical data extends the table model to trees.