CREATE OR REPLACE VIEW order_summary ASSELECT o.id AS order_id, o.user_id, u.full_name, o.status, o.created_at, sum(oi.quantity * oi.unit_price) AS total, count(*) AS line_itemsFROM orders oJOIN users u ON u.id = o.user_idJOIN order_items oi ON oi.order_id = o.idGROUP BY o.id, u.full_name;SELECT full_name, count(*) AS orders, sum(total) AS spendFROM order_summaryWHERE status <> 'cancelled'GROUP BY full_nameORDER BY spend DESC;
Properties
Always Fresh: The query runs each time; the planner inlines the view and pushes filters into it
Abstraction / Security: Expose a subset of columns/rows; grant on the view, not the base table
CREATE OR REPLACE: Can add columns at the end, but can’t drop/rename/retype — use DROP VIEW + CREATE
Dependencies: Base tables can’t drop a column a view uses (DROP … CASCADE drops the view too)
Updatable Views and CHECK OPTION
Auto-updatable: Simple views (one table, no aggregates/DISTINCT/GROUP BY/LIMIT) accept INSERT/UPDATE/DELETE
WITH CHECK OPTION: Rejects writes that would produce rows invisible through the view
security_invoker (PG 15+): Run with the caller’s permissions/RLS instead of the view owner’s
CREATE OR REPLACE VIEW active_products ASSELECT id, sku, name, price, stock, is_activeFROM productsWHERE is_activeWITH CHECK OPTION;BEGIN;UPDATE active_products SET price = price + 1 WHERE sku = 'ACC-001'; -- writes through to products-- UPDATE active_products SET is_active = false WHERE sku = 'ACC-001';-- ERROR: new row violates check option for view "active_products"ROLLBACK;
Materialized Views
Creating and Refreshing
CREATE MATERIALIZED VIEW IF NOT EXISTS monthly_revenue ASSELECT date_trunc('month', o.created_at)::date AS month, count(DISTINCT o.id) AS orders, sum(oi.quantity * oi.unit_price) AS revenueFROM orders oJOIN order_items oi ON oi.order_id = o.idWHERE o.status IN ('paid', 'shipped', 'delivered')GROUP BY 1WITH DATA;-- REFRESH ... CONCURRENTLY requires a UNIQUE index on the matviewCREATE UNIQUE INDEX IF NOT EXISTS monthly_revenue_month_idx ON monthly_revenue (month);SELECT * FROM monthly_revenue ORDER BY month;REFRESH MATERIALIZED VIEW monthly_revenue; -- blocks readers while rebuildingREFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue; -- readers keep seeing old data; diff-applies changes
REFRESH vs REFRESH CONCURRENTLY
Aspect
REFRESH
REFRESH CONCURRENTLY
Lock
ACCESS EXCLUSIVE — blocks SELECT
EXCLUSIVE — SELECT still works
Requirement
None
A UNIQUE index on plain columns; matview already populated
Speed
Faster (truncate + insert)
Slower (computes diff, updates changed rows)
Use When
Off-hours / nobody reading
Dashboards read it continuously
View vs Materialized View vs Table
Aspect
View
Materialized View
Summary Table + Triggers
Freshness
Always current
As of last refresh
Real-time
Read Speed
Same as underlying query
Fast (indexed)
Fast
Write Cost
None
Refresh cost (whole query)
Per-write trigger cost
Indexes
No (uses base table’s)
Yes
Yes
Scheduling
n/a
pg_cron / app job
n/a
Functions
SQL Functions
LANGUAGE sql: Body is plain SQL; simple ones can be inlined into the calling query
Volatility: IMMUTABLE (same input → same output forever; required for expression indexes), STABLE (consistent within a statement, may read tables), VOLATILE (default; random(), writes)
PARALLEL SAFE: Mark it so parallel plans can use it
SQL-standard Body (PG 14+): BEGIN ATOMIC … END — parsed at creation, dependencies tracked
CREATE OR REPLACE FUNCTION order_total(p_order_id bigint)RETURNS numericLANGUAGE sql STABLE PARALLEL SAFEAS $$ SELECT COALESCE(sum(quantity * unit_price), 0) FROM order_items WHERE order_id = p_order_id$$;SELECT id, status, order_total(id) FROM orders WHERE user_id = 1 ORDER BY id;-- Set-returning function: behaves like a parameterised viewCREATE OR REPLACE FUNCTION user_orders(p_user_id bigint)RETURNS TABLE (order_id bigint, status text, total numeric)LANGUAGE sql STABLEAS $$ SELECT o.id, o.status, order_total(o.id) FROM orders o WHERE o.user_id = p_user_id ORDER BY o.created_at$$;SELECT * FROM user_orders(2);
PL/pgSQL Functions
Use When: You need variables, IF, loops, exceptions, dynamic SQL
RAISE: NOTICE for debugging, EXCEPTION to abort with an error
Performance: Each statement inside is planned (and cached); avoid row-by-row loops when one set-based query works
CREATE OR REPLACE FUNCTION apply_discount(p_sku text, p_percent numeric)RETURNS numericLANGUAGE plpgsqlAS $$DECLARE v_new_price numeric;BEGIN IF p_percent <= 0 OR p_percent >= 90 THEN RAISE EXCEPTION 'discount % out of range', p_percent USING ERRCODE = 'check_violation'; END IF; UPDATE products SET price = round(price * (1 - p_percent / 100), 2) WHERE sku = p_sku RETURNING price INTO v_new_price; IF NOT FOUND THEN RAISE EXCEPTION 'unknown sku %', p_sku; END IF; RAISE NOTICE 'new price for % is %', p_sku, v_new_price; RETURN v_new_price;END;$$;BEGIN;SELECT apply_discount('LAP-001', 10); -- 899.10-- SELECT apply_discount('LAP-001', 95); -- ERROR: discount 95 out of rangeROLLBACK;
Procedures
-- Procedures are CALLed and may COMMIT between batches (not allowed in functions)CREATE OR REPLACE PROCEDURE archive_failed_payments(p_batch int DEFAULT 1000)LANGUAGE plpgsqlAS $$DECLARE v_rows int;BEGIN LOOP DELETE FROM payments WHERE id IN (SELECT id FROM payments WHERE status = 'failed' LIMIT p_batch); GET DIAGNOSTICS v_rows = ROW_COUNT; EXIT WHEN v_rows = 0; COMMIT; -- release locks / WAL per batch END LOOP;END;$$;-- CALL archive_failed_payments(500); -- must run OUTSIDE an explicit BEGIN block:-- inside one it fails with ERROR: invalid transaction termination
Triggers
Concepts
Option
Choices
Notes
Timing
BEFORE, AFTER, INSTEAD OF (views)
BEFORE can modify NEW or skip the row (return NULL)
Event
INSERT, UPDATE [OF cols], DELETE, TRUNCATE
Level
FOR EACH ROW, FOR EACH STATEMENT
Statement triggers can use transition tables (REFERENCING NEW TABLE AS …)
Condition
WHEN (OLD.x IS DISTINCT FROM NEW.x)
Skip calling the function when nothing relevant changed
Variables
NEW, OLD, TG_OP, TG_TABLE_NAME
NEW is NULL for DELETE, OLD for INSERT
Example 1: Maintain updated_at
ALTER TABLE products ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();CREATE OR REPLACE FUNCTION set_updated_at()RETURNS triggerLANGUAGE plpgsqlAS $$BEGIN NEW.updated_at := now(); RETURN NEW; -- BEFORE trigger: return the (modified) rowEND;$$;CREATE OR REPLACE TRIGGER products_set_updated_atBEFORE UPDATE ON productsFOR EACH ROWWHEN (OLD.* IS DISTINCT FROM NEW.*) -- skip no-op updatesEXECUTE FUNCTION set_updated_at();
Example 2: Audit Log
CREATE TABLE IF NOT EXISTS audit_log ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, table_name text NOT NULL, op text NOT NULL, row_id bigint, old_row jsonb, new_row jsonb, changed_by text NOT NULL DEFAULT current_user, changed_at timestamptz NOT NULL DEFAULT now());CREATE OR REPLACE FUNCTION audit_trigger()RETURNS triggerLANGUAGE plpgsqlAS $$BEGIN INSERT INTO audit_log (table_name, op, row_id, old_row, new_row) VALUES (TG_TABLE_NAME, TG_OP, COALESCE(NEW.id, OLD.id), CASE WHEN TG_OP <> 'INSERT' THEN to_jsonb(OLD) END, CASE WHEN TG_OP <> 'DELETE' THEN to_jsonb(NEW) END); RETURN NULL; -- AFTER trigger: return value ignoredEND;$$;CREATE OR REPLACE TRIGGER orders_auditAFTER INSERT OR UPDATE OR DELETE ON ordersFOR EACH ROW EXECUTE FUNCTION audit_trigger();BEGIN;UPDATE orders SET status = 'cancelled' WHERE id = 15;UPDATE products SET stock = stock - 1 WHERE sku = 'BOK-001';SELECT op, row_id, old_row ->> 'status' AS old_status, new_row ->> 'status' AS new_statusFROM audit_log;SELECT sku, updated_at > created_at AS touched FROM products WHERE sku = 'BOK-001';ROLLBACK;
Example 3: Enforce a Business Rule
-- Prevent changing items of an order that has already shippedCREATE OR REPLACE FUNCTION forbid_shipped_item_changes()RETURNS triggerLANGUAGE plpgsqlAS $$BEGIN IF EXISTS (SELECT 1 FROM orders WHERE id = COALESCE(NEW.order_id, OLD.order_id) AND status IN ('shipped', 'delivered')) THEN RAISE EXCEPTION 'order % is already %', COALESCE(NEW.order_id, OLD.order_id), 'shipped/delivered'; END IF; RETURN COALESCE(NEW, OLD);END;$$;CREATE OR REPLACE TRIGGER order_items_guardBEFORE INSERT OR UPDATE OR DELETE ON order_itemsFOR EACH ROW EXECUTE FUNCTION forbid_shipped_item_changes();-- DELETE FROM order_items WHERE order_id = 1;-- ERROR: order 1 is already shipped/deliveredBEGIN;DELETE FROM order_items WHERE order_id = 10 RETURNING *; -- pending order: allowedROLLBACK;
Trigger Pitfalls
Hidden Logic: Behaviour invisible to app developers — document triggers and keep them small
Performance: Row-level triggers fire per row; bulk loads slow down dramatically (use statement-level + transition tables, or disable during loads)
Recursion: A trigger updating its own table can re-fire itself — guard with WHEN or pg_trigger_depth()
Ordering: Multiple triggers on the same event fire in alphabetical name order
Not Fired By: TRUNCATE doesn’t fire row-level DELETE triggers; COPY does fire row triggers
Inspecting Objects
-- psql: \dv \dm \df \dS+ products (shows triggers)SELECT viewname FROM pg_views WHERE schemaname = 'public';SELECT matviewname, ispopulated FROM pg_matviews;SELECT tgname, tgrelid::regclass AS on_table FROM pg_trigger WHERE NOT tgisinternal ORDER BY tgname;SELECT proname, prokind FROM pg_proc WHERE pronamespace = 'public'::regnamespace ORDER BY proname;
Interview Questions
“View vs materialized view?”: View re-runs the query; matview stores results and must be refreshed
“Refresh a matview without blocking readers?”: REFRESH MATERIALIZED VIEW CONCURRENTLY + a unique index
“Function vs procedure?”: Functions return values and run inside the caller’s transaction; procedures are CALLed and can COMMIT
“IMMUTABLE vs STABLE vs VOLATILE?”: Determines caching and index eligibility — only IMMUTABLE functions can be used in index expressions
“BEFORE vs AFTER trigger?”: BEFORE can change/skip the row; AFTER sees the final row and is used for side effects like audit logs
“Why are triggers controversial?”: Hidden side effects, harder debugging, per-row overhead
Summary
Views: Saved queries, always fresh, can be updatable with WITH CHECK OPTION
Materialized Views: Cached results, indexable; REFRESH … CONCURRENTLY needs a unique index
Functions: SQL for simple/inlinable logic, PL/pgSQL for control flow; set volatility correctly
Procedures: CALL, can commit in loops — batch maintenance jobs
Triggers: updated_at, audit logs, invariants — keep them small, documented and set-based where possible