Lesson 14 of 30
Views and Routines
These objects store logic inside the database.
| Object | What it stores | Runs |
|---|---|---|
| View | a named SELECT |
every time it is queried |
| Materialized view | a SELECT and its result rows |
result is computed at creation and at each REFRESH |
| Function | code that returns a value or a set of rows | inside a query or statement |
| Procedure | code invoked with CALL, can commit or roll back |
on demand |
| Trigger | a function attached to a table event | automatically on INSERT, UPDATE, DELETE, TRUNCATE |
The demos copy employees into demo_emp so the sample tables stay unchanged. All demo_ objects are dropped at the end.
CREATE TABLE demo_emp AS SELECT id, name, dept_id, salary FROM employees;
ALTER TABLE demo_emp ADD PRIMARY KEY (id);
SELECT count(*) AS rows_copied FROM demo_emp; rows_copied
-------------
12
(1 row)Example 1: A view
A view saves a query under a name. It stores no data; the query runs each time the view is used.
CREATE VIEW demo_dept_summary AS
SELECT d.name AS department, count(e.id) AS headcount, round(avg(e.salary)) AS avg_salary
FROM departments d
LEFT JOIN employees e ON e.dept_id = d.id
GROUP BY d.name;
SELECT * FROM demo_dept_summary ORDER BY department; department | headcount | avg_salary
-------------+-----------+------------
Engineering | 5 | 168000
HR | 1 | 95000
Legal | 0 |
Marketing | 2 | 100000
Sales | 3 | 111667
(5 rows)Views hide join logic, give a stable interface when tables change, and expose only selected columns. A view can be filtered and joined like a table:
SELECT department FROM demo_dept_summary WHERE headcount = 0; department
------------
Legal
(1 row)CREATE OR REPLACE VIEW changes the query as long as existing columns keep their names and types (new columns can only be added at the end).
Example 2: An updatable view
A simple view (one table, no GROUP BY, DISTINCT, aggregates, or set operations) accepts INSERT, UPDATE, and DELETE, which pass through to the base table. WITH CHECK OPTION rejects changes that would make a row disappear from the view.
CREATE VIEW demo_engineers AS
SELECT id, name, dept_id, salary
FROM demo_emp
WHERE dept_id = 1
WITH CHECK OPTION;
UPDATE demo_engineers SET salary = salary + 1000 WHERE name = 'Arjun';
SELECT id, name, salary FROM demo_emp WHERE name = 'Arjun'; id | name | salary
----+-------+--------
5 | Arjun | 111000
(1 row)The update reached demo_emp. Moving a row out of the view's filter fails:
UPDATE demo_engineers SET dept_id = 2 WHERE name = 'Arjun';ERROR: new row violates check option for view "demo_engineers"
DETAIL: Failing row contains (5, Arjun, 2, 111000).Rows outside the view are not visible or changeable through it. Divya (Sales) survives a delete through the view:
DELETE FROM demo_engineers WHERE name = 'Divya';
SELECT count(*) AS divya_rows FROM demo_emp WHERE name = 'Divya'; divya_rows
------------
1
(1 row)Example 3: A view that is not updatable
A view with an aggregate has no single base row to change.
UPDATE demo_dept_summary SET headcount = 10 WHERE department = 'HR';ERROR: cannot update view "demo_dept_summary"
DETAIL: Views containing GROUP BY are not automatically updatable.
HINT: To enable updating the view, provide an INSTEAD OF UPDATE trigger or an unconditional ON UPDATE DO INSTEAD rule.An INSTEAD OF trigger on the view can define what the change means. SQL Server and Oracle also support INSTEAD OF triggers on views.
Example 4: Materialized view and REFRESH
A materialized view stores its result on disk. Reads are fast, but the data is stale until refreshed.
CREATE MATERIALIZED VIEW demo_dept_totals AS
SELECT dept_id, count(*) AS headcount, sum(salary) AS payroll
FROM demo_emp
GROUP BY dept_id;
SELECT * FROM demo_dept_totals WHERE dept_id = 2; dept_id | headcount | payroll
---------+-----------+---------
2 | 3 | 335000
(1 row)Changing the base table does not change the materialized view:
UPDATE demo_emp SET salary = salary + 10000 WHERE dept_id = 2;
SELECT * FROM demo_dept_totals WHERE dept_id = 2; dept_id | headcount | payroll
---------+-----------+---------
2 | 3 | 335000
(1 row)REFRESH MATERIALIZED VIEW recomputes it.
REFRESH MATERIALIZED VIEW demo_dept_totals;
SELECT * FROM demo_dept_totals WHERE dept_id = 2; dept_id | headcount | payroll
---------+-----------+---------
2 | 3 | 365000
(1 row)A plain refresh locks the view against reads while it runs. REFRESH MATERIALIZED VIEW CONCURRENTLY avoids that but needs a unique index on the view.
CREATE UNIQUE INDEX demo_dept_totals_uq ON demo_dept_totals (dept_id);
REFRESH MATERIALIZED VIEW CONCURRENTLY demo_dept_totals;PostgreSQL has no automatic incremental refresh. Oracle offers REFRESH FAST and ON COMMIT, and SQL Server "indexed views" are maintained automatically on every write.
Example 5: Indexes and views
A regular view cannot have an index; queries on it use the indexes of the base tables, because the view is expanded into the query. A materialized view is a physical table, so it can be indexed.
CREATE INDEX demo_emp_dept_idx ON demo_emp (dept_id);
SET enable_seqscan = off;
EXPLAIN (COSTS OFF)
SELECT * FROM demo_engineers WHERE dept_id = 1;
EXPLAIN (COSTS OFF)
SELECT * FROM demo_dept_totals WHERE dept_id = 2;
RESET enable_seqscan; QUERY PLAN
------------------------------------------------
Index Scan using demo_emp_dept_idx on demo_emp
Index Cond: (dept_id = 1)
(2 rows)
QUERY PLAN
----------------------------------------------------------
Index Scan using demo_dept_totals_uq on demo_dept_totals
Index Cond: (dept_id = 2)
(2 rows)The first plan uses the index on demo_emp, the second uses the index on the materialized view.
Example 6: A SQL function
A function returns a value and can be used inside queries.
CREATE FUNCTION demo_annual_tax(salary numeric, rate numeric DEFAULT 0.10)
RETURNS numeric
LANGUAGE sql IMMUTABLE
AS $$ SELECT round(salary * rate, 2) $$;
SELECT name, salary, demo_annual_tax(salary) AS tax_default, demo_annual_tax(salary, 0.2) AS tax_20
FROM demo_emp
WHERE dept_id = 4; name | salary | tax_default | tax_20
-------+--------+-------------+----------
Rohan | 95000 | 9500.00 | 19000.00
(1 row)IMMUTABLE tells the planner the result depends only on the arguments, so it can be cached or used in an index expression.
Example 7: A function returning rows (PL/pgSQL)
PL/pgSQL adds variables, IF, and loops. A function can return a table.
CREATE FUNCTION demo_top_earners(p_dept int, p_limit int)
RETURNS TABLE (name text, salary int)
LANGUAGE plpgsql STABLE
AS $$
BEGIN
IF p_limit < 1 THEN
RAISE EXCEPTION 'limit must be positive, got %', p_limit;
END IF;
RETURN QUERY
SELECT e.name, e.salary
FROM demo_emp e
WHERE e.dept_id = p_dept
ORDER BY e.salary DESC, e.name
LIMIT p_limit;
END;
$$;
SELECT * FROM demo_top_earners(1, 2); name | salary
-------+--------
Meera | 250000
Ravi | 180000
(2 rows)SELECT * FROM demo_top_earners(1, 0);ERROR: limit must be positive, got 0
CONTEXT: PL/pgSQL function demo_top_earners(integer,integer) line 4 at RAISEOracle and SQL Server use PL/SQL and T-SQL; MySQL uses its own stored-program syntax. The concepts are the same.
Example 8: A stored procedure
A procedure is called with CALL, returns no value, and can run several statements and control transactions (COMMIT, ROLLBACK). A function cannot commit.
CREATE TABLE demo_accounts (id int PRIMARY KEY, owner text, balance numeric(10,2) CHECK (balance >= 0));
INSERT INTO demo_accounts VALUES (1, 'Asha', 500), (2, 'Bala', 100);
CREATE PROCEDURE demo_transfer(p_from int, p_to int, p_amount numeric)
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE demo_accounts SET balance = balance - p_amount WHERE id = p_from;
UPDATE demo_accounts SET balance = balance + p_amount WHERE id = p_to;
END;
$$;
CALL demo_transfer(1, 2, 200);
SELECT * FROM demo_accounts ORDER BY id; id | owner | balance
----+-------+---------
1 | Asha | 300.00
2 | Bala | 300.00
(2 rows)A transfer that breaks the CHECK fails, and no balance changes:
CALL demo_transfer(1, 2, 1000);
SELECT * FROM demo_accounts ORDER BY id;ERROR: new row for relation "demo_accounts" violates check constraint "demo_accounts_balance_check"
DETAIL: Failing row contains (1, Asha, -700.00).
CONTEXT: SQL statement "UPDATE demo_accounts SET balance = balance - p_amount WHERE id = p_from"
PL/pgSQL function demo_transfer(integer,integer,numeric) line 3 at SQL statement
id | owner | balance
----+-------+---------
1 | Asha | 300.00
2 | Bala | 300.00
(2 rows)Example 9: A trigger for an audit log
A trigger runs a trigger function automatically when a row changes. OLD holds the row before the change and NEW the row after.
CREATE TABLE demo_salary_audit (
audit_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
emp_id int,
action text,
old_salary int,
new_salary int,
changed_at timestamptz DEFAULT now()
);
CREATE FUNCTION demo_log_salary() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
INSERT INTO demo_salary_audit (emp_id, action, old_salary)
VALUES (OLD.id, TG_OP, OLD.salary);
RETURN OLD;
ELSIF TG_OP = 'UPDATE' AND NEW.salary IS DISTINCT FROM OLD.salary THEN
INSERT INTO demo_salary_audit (emp_id, action, old_salary, new_salary)
VALUES (OLD.id, TG_OP, OLD.salary, NEW.salary);
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER demo_emp_audit
AFTER UPDATE OR DELETE ON demo_emp
FOR EACH ROW EXECUTE FUNCTION demo_log_salary();
UPDATE demo_emp SET salary = 300000 WHERE name = 'Meera';
UPDATE demo_emp SET name = 'Anita R.' WHERE name = 'Anita';
DELETE FROM demo_emp WHERE name = 'Tara';
SELECT emp_id, action, old_salary, new_salary FROM demo_salary_audit ORDER BY audit_id; emp_id | action | old_salary | new_salary
--------+--------+------------+------------
1 | UPDATE | 250000 | 300000
12 | DELETE | 70000 |
(2 rows)Renaming Anita produced no audit row because the salary did not change. Row-level triggers fire once per affected row; FOR EACH STATEMENT triggers fire once per statement. BEFORE triggers can modify NEW or cancel the change by returning NULL.
Example 10: A validation trigger
A BEFORE trigger can reject changes with custom rules that a CHECK cannot express, such as comparing to other rows or forbidding a decrease.
CREATE FUNCTION demo_no_pay_cut() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.salary < OLD.salary THEN
RAISE EXCEPTION 'salary cut for employee % is not allowed', OLD.id;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER demo_emp_no_cut
BEFORE UPDATE ON demo_emp
FOR EACH ROW EXECUTE FUNCTION demo_no_pay_cut();
UPDATE demo_emp SET salary = 100 WHERE name = 'Ravi';ERROR: salary cut for employee 2 is not allowed
CONTEXT: PL/pgSQL function demo_no_pay_cut() line 4 at RAISESELECT name, salary FROM demo_emp WHERE name = 'Ravi'; name | salary
------+--------
Ravi | 180000
(1 row)Cleanup:
DROP MATERIALIZED VIEW demo_dept_totals;
DROP VIEW demo_engineers, demo_dept_summary;
DROP TABLE demo_salary_audit, demo_accounts, demo_emp;
DROP FUNCTION demo_log_salary(), demo_no_pay_cut(), demo_annual_tax(numeric, numeric),
demo_top_earners(int, int);
DROP PROCEDURE demo_transfer(int, int, numeric);Interview points
View versus materialized view
| View | Materialized view | |
|---|---|---|
| Stores data | no, only the query | yes, the result |
| Freshness | always current | stale until refreshed |
| Read cost | cost of the underlying query | cost of a table scan |
| Indexable | no (base table indexes apply) | yes |
| Updatable | simple views only | never |
Stored procedure versus function
| Function | Procedure | |
|---|---|---|
| Called with | SELECT f(...), inside expressions |
CALL p(...) |
| Return value | required (a value or a table) | none (only OUT parameters) |
| Transaction control | none | COMMIT / ROLLBACK allowed (PostgreSQL 11+) |
| Use in a query | yes | no |
Trigger downsides
- Logic is hidden: a plain
INSERTcan cause other writes, which surprises developers and makes debugging harder. - Every affected row pays the cost, so bulk loads slow down; row-level triggers on large updates are expensive.
- Triggers can cascade into other triggers and cause loops or deadlocks.
- They are harder to version, test, and port between databases.
- Bulk-load tools may disable them, so the rules are not guaranteed in every path.
Prefer constraints for integrity rules, and use triggers for cross-cutting behaviors such as auditing. Other points interviewers ask: a view with WITH CHECK OPTION, refreshing a materialized view without blocking readers (CONCURRENTLY with a unique index), and why a regular view does not improve performance by itself (it is expanded into the calling query).
Summary
- A view is a saved query with no stored data; simple single-table views are updatable, and
WITH CHECK OPTIONkeeps changes inside the filter. - A materialized view stores results and must be refreshed; it can be indexed, and
CONCURRENTLYneeds a unique index. - Functions return values and run inside queries; procedures run with
CALLand can control transactions. - Triggers run automatically per row or statement, and
OLD/NEWgive access to the before and after rows. - Triggers hide logic and add write cost, so constraints are preferred for plain integrity rules.
