Guide contents

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.

SQL
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;
Output
 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.

SQL
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;
Output
 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:

SQL
SELECT department FROM demo_dept_summary WHERE headcount = 0;
Output
 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.

SQL
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';
Output
 id | name  | salary 
----+-------+--------
  5 | Arjun | 111000
(1 row)

The update reached demo_emp. Moving a row out of the view's filter fails:

SQL
UPDATE demo_engineers SET dept_id = 2 WHERE name = 'Arjun';
Output
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:

SQL
DELETE FROM demo_engineers WHERE name = 'Divya';

SELECT count(*) AS divya_rows FROM demo_emp WHERE name = 'Divya';
Output
 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.

SQL
UPDATE demo_dept_summary SET headcount = 10 WHERE department = 'HR';
Output
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.

SQL
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;
Output
 dept_id | headcount | payroll 
---------+-----------+---------
       2 |         3 |  335000
(1 row)

Changing the base table does not change the materialized view:

SQL
UPDATE demo_emp SET salary = salary + 10000 WHERE dept_id = 2;

SELECT * FROM demo_dept_totals WHERE dept_id = 2;
Output
 dept_id | headcount | payroll 
---------+-----------+---------
       2 |         3 |  335000
(1 row)

REFRESH MATERIALIZED VIEW recomputes it.

SQL
REFRESH MATERIALIZED VIEW demo_dept_totals;

SELECT * FROM demo_dept_totals WHERE dept_id = 2;
Output
 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.

SQL
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.

SQL
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;
Output
                   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.

SQL
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;
Output
 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.

SQL
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);
Output
 name  | salary 
-------+--------
 Meera | 250000
 Ravi  | 180000
(2 rows)
SQL
SELECT * FROM demo_top_earners(1, 0);
Output
ERROR:  limit must be positive, got 0
CONTEXT:  PL/pgSQL function demo_top_earners(integer,integer) line 4 at RAISE

Oracle 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.

SQL
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;
Output
 id | owner | balance 
----+-------+---------
  1 | Asha  |  300.00
  2 | Bala  |  300.00
(2 rows)

A transfer that breaks the CHECK fails, and no balance changes:

SQL
CALL demo_transfer(1, 2, 1000);
SELECT * FROM demo_accounts ORDER BY id;
Output
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.

SQL
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;
Output
 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.

SQL
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';
Output
ERROR:  salary cut for employee 2 is not allowed
CONTEXT:  PL/pgSQL function demo_no_pay_cut() line 4 at RAISE
SQL
SELECT name, salary FROM demo_emp WHERE name = 'Ravi';
Output
 name | salary 
------+--------
 Ravi | 180000
(1 row)

Cleanup:

SQL
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 INSERT can 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 OPTION keeps changes inside the filter.
  • A materialized view stores results and must be refreshed; it can be indexed, and CONCURRENTLY needs a unique index.
  • Functions return values and run inside queries; procedures run with CALL and can control transactions.
  • Triggers run automatically per row or statement, and OLD/NEW give access to the before and after rows.
  • Triggers hide logic and add write cost, so constraints are preferred for plain integrity rules.