Triggers — Automating Audit Trails and Derived Data
Triggers — Automating Audit Trails and Derived Data
A trigger is a function that executes automatically in response to a row-level or statement-level event on a table. Triggers ensure that business rules are enforced at the database level, independent of which application or API writes to the table.
Trigger types
| Type | When it fires | Use case |
|---|---|---|
BEFORE row trigger |
Before each row is inserted/updated/deleted | Validate or modify values before they're written |
AFTER row trigger |
After each row is inserted/updated/deleted | Audit logs, maintaining denormalised fields |
INSTEAD OF trigger |
On views | Make views updatable |
| Statement-level trigger | Once per statement | Logging at statement level |
Example 1: Audit log trigger
-- Audit log table
CREATE TABLE IF NOT EXISTS audit_trail (
audit_id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
operation TEXT NOT NULL, -- INSERT, UPDATE, DELETE
old_data JSONB,
new_data JSONB,
changed_at TIMESTAMPTZ DEFAULT NOW(),
changed_by TEXT DEFAULT CURRENT_USER
);
-- Generic audit trigger function (works for any table)
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_trail (table_name, record_id, operation, old_data, new_data)
VALUES (
TG_TABLE_NAME,
CASE TG_OP
WHEN 'DELETE' THEN (row_to_json(OLD) ->> 'invoice_id')
ELSE (row_to_json(NEW) ->> 'invoice_id')
END,
TG_OP,
CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN row_to_json(OLD)::JSONB ELSE NULL END,
CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN row_to_json(NEW)::JSONB ELSE NULL END
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Attach to the invoices table
CREATE TRIGGER audit_invoices
AFTER INSERT OR UPDATE OR DELETE ON invoices
FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
-- Test it:
UPDATE invoices SET status = 'paid' WHERE invoice_id = 1;
SELECT * FROM audit_trail WHERE table_name = 'invoices' ORDER BY audit_id DESC LIMIT 3;
Example 2: BEFORE trigger for data validation and enrichment
-- Automatically set invoice_no if not provided, and validate due_date
DROP TRIGGER IF EXISTS before_invoice ON invoices;
CREATE OR REPLACE FUNCTION before_invoice_insert()
RETURNS TRIGGER AS $$
BEGIN
-- Auto-generate invoice_no if not provided
IF NEW.invoice_no IS NULL THEN
NEW.invoice_no := 'INV-' || LPAD(nextval('invoice_seq')::TEXT, 6, '0');
END IF;
-- Due date must be after issue date
IF NEW.due_date <= NEW.issue_date THEN
RAISE EXCEPTION 'due_date (%) must be after issue_date (%)',
NEW.due_date, NEW.issue_date;
END IF;
-- Default to 'sent' status
IF NEW.status IS NULL THEN
NEW.status := 'sent';
END IF;
RETURN NEW; -- BEFORE triggers must return NEW (or NULL to abort)
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER before_invoice
BEFORE INSERT ON invoices
FOR EACH ROW EXECUTE FUNCTION before_invoice_insert();
Example 3: Maintaining denormalised running totals
-- Automatically keep a client_stats table in sync with invoice changes
CREATE TABLE client_stats (
client_id INT PRIMARY KEY REFERENCES clients,
invoice_count INT DEFAULT 0,
total_billed NUMERIC(12,2) DEFAULT 0,
total_collected NUMERIC(12,2) DEFAULT 0,
last_invoice_at DATE
);
CREATE OR REPLACE FUNCTION sync_client_stats()
RETURNS TRIGGER AS $$
BEGIN
-- Upsert the stats row for this client
INSERT INTO client_stats (client_id, invoice_count, total_billed, last_invoice_at)
SELECT
i.client_id,
COUNT(*),
COALESCE(SUM(ii.quantity * ii.unit_price), 0),
MAX(i.issue_date)
FROM invoices i
LEFT JOIN invoice_items ii ON ii.invoice_id = i.invoice_id
WHERE i.client_id = COALESCE(NEW.client_id, OLD.client_id)
GROUP BY i.client_id
ON CONFLICT (client_id) DO UPDATE SET
invoice_count = EXCLUDED.invoice_count,
total_billed = EXCLUDED.total_billed,
last_invoice_at = EXCLUDED.last_invoice_at;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sync_stats_on_invoice
AFTER INSERT OR UPDATE OR DELETE ON invoices
FOR EACH ROW EXECUTE FUNCTION sync_client_stats();
Key Takeaway
Triggers enforce database-level invariants: audit logs that no application can bypass, auto-generated fields, and denormalised caches that stay in sync automatically. BEFORE triggers can modify NEW before it's written; AFTER triggers see the committed state. Use row_to_json(OLD)::JSONB and row_to_json(NEW)::JSONB in audit triggers to capture the full row state without listing every column. RAISE EXCEPTION aborts the transaction with a clear error message.