Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Database Management Systems (DBMS)  Module 5 – SQL semantics: how queries really evaluate

Views, updatable views and materialized views

What a view stores, when PostgreSQL lets you change rows through one, what WITH CHECK OPTION refuses, and how materialized views keep and refresh results.

  • Intermediate
  • 30 minutes
  • Examples run with sql.js 1.14.2 and Python 3.14.8
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Create views for security and logical data independence
  • Predict when PostgreSQL can update through a view and when WITH CHECK OPTION rejects a change
  • Use materialized views and their refresh options, and emulate them in SQLite

Before you start

What you’ll learn · Before you start

What you will learn

  • Create views for security and logical data independence
  • Predict when PostgreSQL can update through a view and when WITH CHECK OPTION rejects a change
  • Use materialized views and their refresh options, and emulate them in SQLite

Before you start

On this page

A view is a query with a name. It is stored in the database and used like a table, but it holds no rows: every query that reads the view runs the stored query against the tables underneath, so the view always shows their current data. Views hide columns and rows that a user should not see, give a long join a short name, and keep old queries working after the tables are reorganised. Some views also accept INSERT, UPDATE and DELETE, which the database turns into changes of the underlying table: PostgreSQL does this by itself for simple views, and WITH CHECK OPTION refuses a change that would create a row the view cannot show. A materialized view works the other way round. It stores the result of its query as rows, so reading it is cheap, but it is only as fresh as its last REFRESH.

SQLite’s views are read-only and it has no materialized views, so each PostgreSQL behaviour below is shown as the output psql printed on PostgreSQL 18.6, starting from an empty database, and is followed by the SQLite workaround. The SQLite queries run on this page, on SQLite 3.49.1 compiled for the browser as sql.js 1.14.2.

A view is a stored query

A view over part of a table SQL · sqlite/view_basics.sql
CREATE TABLE students (
  roll  INTEGER PRIMARY KEY,
  name  TEXT NOT NULL,
  dept  TEXT NOT NULL,
  cgpa  REAL,
  phone TEXT
);
INSERT INTO students VALUES
  (101, 'Asha',  'CSE', 8.9, '90000 00101'),
  (102, 'Ravi',  'ECE', 7.4, '90000 00102'),
  (103, 'Meera', 'CSE', 9.2, '90000 00103');

-- The view names a query. It shows no phone numbers and no other department.
CREATE VIEW cse_directory AS
  SELECT roll, name FROM students WHERE dept = 'CSE';

SELECT * FROM cse_directory ORDER BY roll;

-- A student added to the table appears through the view at once.
INSERT INTO students VALUES (104, 'Kabir', 'CSE', 6.8, '90000 00104');
SELECT * FROM cse_directory ORDER BY roll;

-- What SQLite stored for the view: the text of its definition, no rows.
SELECT type, name, sql FROM sqlite_schema WHERE name = 'cse_directory';

Output

┌──────┬───────┐
│ roll │ name  │
├──────┼───────┤
│ 101  │ Asha  │
│ 103  │ Meera │
└──────┴───────┘
┌──────┬───────┐
│ roll │ name  │
├──────┼───────┤
│ 101  │ Asha  │
│ 103  │ Meera │
│ 104  │ Kabir │
└──────┴───────┘
┌──────┬───────────────┬──────────────────────────────────────────────────────┐
│ type │     name      │                         sql                          │
├──────┼───────────────┼──────────────────────────────────────────────────────┤
│ view │ cse_directory │ CREATE VIEW cse_directory AS                         │
│      │               │   SELECT roll, name FROM students WHERE dept = 'CSE' │
└──────┴───────────────┴──────────────────────────────────────────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < view_basics.sql

  • The view shows two columns of the CSE rows only. Ravi’s row and every phone number stay out of it.
  • Kabir, inserted into students after the view was created, appears through it straight away. Nothing was copied when the view was made, so there is nothing to keep in step.
  • The schema table holds the view’s definition as text, and no data. SQLite parses that text again whenever the schema is loaded.

PostgreSQL stores a parsed query instead of text. Its views are built on the rule system: a view is a relation with no storage and an ON SELECT DO INSTEAD rule, conventionally named _RETURN. When a query names a view, the rewriter replaces the view by its stored query, and a view built on another view is replaced again, until only tables are left. The planner then pulls those subqueries up into one query and plans it as if you had written it out by hand:

Two views deep, one scan of the table Python · pg/rewrite.py
# What PostgreSQL keeps for a view, and what the planner sees when a query uses one.
from pg_session import psql

psql("""
CREATE TABLE students (roll integer PRIMARY KEY, name text NOT NULL, dept text NOT NULL, cgpa numeric(3,1));
INSERT INTO students VALUES (101, 'Asha', 'CSE', 9.1);
CREATE VIEW cse AS SELECT * FROM students WHERE dept = 'CSE';
CREATE VIEW cse_toppers AS SELECT roll, name FROM cse WHERE cgpa >= 9;

-- Two views deep, but the plan reads the table once, with both conditions.
EXPLAIN (COSTS OFF) SELECT name FROM cse_toppers;

-- The stored definition: * was replaced by the columns the table had.
SELECT pg_get_viewdef('cse');
ALTER TABLE students ADD COLUMN hostel text DEFAULT 'Kaveri';
SELECT * FROM cse;

-- The views depend on these columns, so they cannot simply be dropped.
ALTER TABLE students DROP COLUMN cgpa;
""")

Output

CREATE TABLE
INSERT 0 1
CREATE VIEW
CREATE VIEW
                         QUERY PLAN                          
-------------------------------------------------------------
 Seq Scan on students
   Filter: ((cgpa >= '9'::numeric) AND (dept = 'CSE'::text))
(2 rows)

        pg_get_viewdef         
-------------------------------
  SELECT roll,                +
     name,                    +
     dept,                    +
     cgpa                     +
    FROM students             +
   WHERE (dept = 'CSE'::text);
(1 row)

ALTER TABLE
 roll | name | dept | cgpa 
------+------+------+------
  101 | Asha | CSE  |  9.1
(1 row)

ERROR:  cannot drop column cgpa of table students because other objects depend on it
DETAIL:  view cse depends on column cgpa of table students
view cse_toppers depends on view cse
HINT:  Use DROP ... CASCADE to drop the dependent objects too.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rewrite.py

Two flows: a query on a view is rewritten into a query on the base table; a materialized view is read like a table.A viewA materialized viewSELECT nameFROM cse_toppersRewriter: each view isreplaced by its stored queryPlanner: one query onstudents, with the conditionsof both viewsTable students:its rows right nowREFRESH MATERIALIZED VIEW:runs the stored queryStored rows: what the queryreturned at the last refreshSELECT * FROM course_sizesgets the stored rowsreplacesread as they are

A view runs its query on every read; a materialized view stores the result

Text description of the diagram

The diagram has two parts, one above the other.

The first part shows a view. A query such as SELECT name FROM cse_toppers goes to the rewriter, which replaces each view it names by the query stored for that view, so a view built on another view is replaced twice. The planner then gets a single query on the table students that carries the conditions of both views, and the executor reads the rows the table holds at that moment. Nothing about the view itself is read.

The second part shows a materialized view. REFRESH MATERIALIZED VIEW runs the stored query and replaces the stored rows with its result. A query such as SELECT * FROM course_sizes reads those stored rows directly, like a table, without running the query again, so it sees what the query returned at the last refresh.

The plan for cse_toppers is a single scan of students with the conditions of both views joined by AND; the views themselves cost nothing at run time. The session also shows a difference between the two engines. PostgreSQL replaced * by the four columns students had when the view was created, so the column hostel, added later, does not appear through cse, and the dependency stops anyone dropping a column that a view uses. SQLite keeps the text and reads it again:

SELECT * in a SQLite view follows the table SQL · sqlite/star_view.sql
CREATE TABLE students (roll INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO students VALUES (101, 'Asha');
CREATE VIEW everyone AS SELECT * FROM students;

-- Add a column after the view was created.
ALTER TABLE students ADD COLUMN hostel TEXT DEFAULT 'Kaveri';

-- SQLite reads the view's text again, so * now includes the new column.
SELECT * FROM everyone;

Output

┌──────┬──────┬────────┐
│ roll │ name │ hostel │
├──────┼──────┼────────┤
│ 101  │ Asha │ Kaveri │
└──────┴──────┴────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < star_view.sql

Name the columns in a view’s SELECT list rather than writing *. The view then means the same thing in every engine, and a new column never appears in a view by surprise.

What views are for

Restricting access. A user can be allowed to read a view without being allowed to read its table. By default PostgreSQL checks the table against the privileges of the view’s owner, so the view decides which columns and rows are visible. (The view option security_invoker checks the table against the user of the view instead.) The access control lesson shows such a grant at work.

Logical data independence. Applications are written against the names and columns they query. If those come from a view, the tables underneath can change shape while the view keeps the old one. PostgreSQL’s tutorial recommends using views freely for this reason: they put a stable interface in front of tables that change as an application grows. Here hostel rooms move out of students into their own table, and an old report keeps working:

A new table design behind the old name SQL · sqlite/independence.sql
-- First design: hostel rooms live in the students table.
CREATE TABLE students (
  roll INTEGER PRIMARY KEY, name TEXT NOT NULL, dept TEXT NOT NULL,
  hostel TEXT, room INTEGER
);
INSERT INTO students VALUES
  (101, 'Asha', 'CSE', 'Kaveri', 12), (102, 'Ravi', 'ECE', NULL, NULL),
  (103, 'Meera', 'CSE', 'Godavari', 7);

-- A report that applications already run.
SELECT name, hostel, room FROM students WHERE dept = 'CSE' ORDER BY roll;

-- Second design: rooms move to a table of their own.
CREATE TABLE people (roll INTEGER PRIMARY KEY, name TEXT NOT NULL, dept TEXT NOT NULL);
CREATE TABLE rooms (
  roll INTEGER PRIMARY KEY REFERENCES people (roll),
  hostel TEXT NOT NULL, room INTEGER NOT NULL
);
INSERT INTO people SELECT roll, name, dept FROM students;
INSERT INTO rooms SELECT roll, hostel, room FROM students WHERE hostel IS NOT NULL;
DROP TABLE students;

-- A view with the old name and the old columns.
CREATE VIEW students AS
  SELECT p.roll, p.name, p.dept, r.hostel, r.room
  FROM people AS p LEFT JOIN rooms AS r ON r.roll = p.roll;

-- The same report, unchanged, gives the same answer.
SELECT name, hostel, room FROM students WHERE dept = 'CSE' ORDER BY roll;

Output

┌───────┬──────────┬──────┐
│ name  │  hostel  │ room │
├───────┼──────────┼──────┤
│ Asha  │ Kaveri   │ 12   │
│ Meera │ Godavari │ 7    │
└───────┴──────────┴──────┘
┌───────┬──────────┬──────┐
│ name  │  hostel  │ room │
├───────┼──────────┼──────┤
│ Asha  │ Kaveri   │ 12   │
│ Meera │ Godavari │ 7    │
└───────┴──────────┴──────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < independence.sql

The report printed the same three rows before and after. Ravi has no room, so the view’s LEFT JOIN keeps him with empty hostel columns, exactly as the old table did, and the report never mentions people or rooms.

Naming a query. A join of five tables with the right conditions, written once as a view, cannot be got wrong in the next report that needs it.

Changing rows through a view

PostgreSQL makes a view automatically updatable when it meets all of these conditions:

  • its FROM list has exactly one entry, a table or another updatable view;
  • it has no WITH, DISTINCT, GROUP BY, HAVING, LIMIT or OFFSET at the top level;
  • it has no UNION, INTERSECT or EXCEPT at the top level;
  • its select list has no aggregates, window functions or set-returning functions.

Then INSERT, UPDATE, DELETE and MERGE on the view become the same statements on the table. A column of the view is updatable when it is a plain reference to a column of the table; a computed column such as upper(name) is read-only. An INSERT can fill only the view’s columns and the rest get their defaults, so a view that leaves out a NOT NULL column without a default cannot accept any INSERT at all.

Three views, three behaviours Python · pg/updatable.py
# Changing rows through views in PostgreSQL 18.
from pg_session import psql

psql("""
CREATE TABLE students (
  roll integer PRIMARY KEY,
  name text NOT NULL,
  dept text NOT NULL,
  cgpa numeric(3,1)
);
INSERT INTO students VALUES
  (101, 'Asha', 'CSE', 8.9), (102, 'Ravi', 'ECE', 7.4),
  (103, 'Meera', 'CSE', 9.2), (104, 'Kabir', 'CSE', 6.8);

CREATE VIEW cse_students AS
  SELECT roll, name, dept FROM students WHERE dept = 'CSE';
CREATE VIEW cse_checked AS
  SELECT roll, name, dept FROM students WHERE dept = 'CSE'
  WITH CHECK OPTION;
CREATE VIEW dept_sizes AS
  SELECT dept, count(*) AS students FROM students GROUP BY dept;

SELECT table_name, is_updatable, is_insertable_into
FROM information_schema.views WHERE table_schema = 'public' ORDER BY table_name;

UPDATE cse_students SET name = 'Asha Rao' WHERE roll = 101;   -- a CSE row: changed
UPDATE cse_students SET name = 'Ravi K' WHERE roll = 102;     -- ECE: not in the view
UPDATE cse_students SET dept = 'ECE' WHERE roll = 103;        -- allowed: Meera leaves
SELECT * FROM cse_students ORDER BY roll;

UPDATE cse_checked SET dept = 'ME' WHERE roll = 104;
INSERT INTO cse_checked VALUES (105, 'Neel', 'ECE');
INSERT INTO cse_checked VALUES (105, 'Neel', 'CSE');
UPDATE dept_sizes SET students = 0;
SELECT * FROM students ORDER BY roll;
""")

Output

CREATE TABLE
INSERT 0 4
CREATE VIEW
CREATE VIEW
CREATE VIEW
  table_name  | is_updatable | is_insertable_into 
--------------+--------------+--------------------
 cse_checked  | YES          | YES
 cse_students | YES          | YES
 dept_sizes   | NO           | NO
(3 rows)

UPDATE 1
UPDATE 0
UPDATE 1
 roll |   name   | dept 
------+----------+------
  101 | Asha Rao | CSE
  104 | Kabir    | CSE
(2 rows)

ERROR:  new row violates check option for view "cse_checked"
DETAIL:  Failing row contains (104, Kabir, ME, 6.8).
ERROR:  new row violates check option for view "cse_checked"
DETAIL:  Failing row contains (105, Neel, ECE, null).
INSERT 0 1
ERROR:  cannot update view "dept_sizes"
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.
 roll |   name   | dept | cgpa 
------+----------+------+------
  101 | Asha Rao | CSE  |  8.9
  102 | Ravi     | ECE  |  7.4
  103 | Meera    | ECE  |  9.2
  104 | Kabir    | CSE  |  6.8
  105 | Neel     | CSE  |     
(5 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 updatable.py

Read the output from the top:

  • information_schema.views already says which views accept changes: the two simple ones do, the grouped one does not.
  • UPDATE 1, UPDATE 0, UPDATE 1: the view’s WHERE decides which rows a statement can reach. Ravi is in ECE, so the second update found no row to change.
  • The third update moved Meera to ECE. That was allowed, and she vanished from cse_students: a change made through a view may produce a row that the view no longer shows.
  • cse_checked was created WITH CHECK OPTION, so the same kind of change was refused, and so was the insert of an ECE student. The insert of a CSE student went through, with cgpa left NULL because the view does not have it.
  • dept_sizes groups its rows, so it is read-only, and the error says what would make it writable: an INSTEAD OF trigger, or a rule.

WITH CHECK OPTION: LOCAL and CASCADED

WITH CHECK OPTION checks that every row an INSERT, UPDATE or MERGE produces through the view is visible through the view. When one view is built on another, the option has two forms. LOCAL checks only the conditions written in the view itself; CASCADED, which is what WITH CHECK OPTION means on its own, also checks the conditions of every view underneath.

LOCAL checks one view, CASCADED checks them all Python · pg/check_options.py
# WITH LOCAL CHECK OPTION against WITH CASCADED CHECK OPTION, on a view built on a view.
from pg_session import psql

psql("""
CREATE TABLE students (roll integer PRIMARY KEY, name text NOT NULL, dept text NOT NULL, cgpa numeric(3,1));
CREATE VIEW cse AS SELECT * FROM students WHERE dept = 'CSE';          -- no check option
CREATE VIEW toppers_local AS SELECT * FROM cse WHERE cgpa >= 9
  WITH LOCAL CHECK OPTION;
CREATE VIEW toppers_cascaded AS SELECT * FROM cse WHERE cgpa >= 9
  WITH CASCADED CHECK OPTION;

INSERT INTO toppers_local VALUES (201, 'Tara', 'ECE', 9.5);      -- only cgpa is checked
INSERT INTO toppers_cascaded VALUES (202, 'Uday', 'ECE', 9.5);   -- dept is checked too
INSERT INTO toppers_cascaded VALUES (203, 'Vani', 'CSE', 8.0);
INSERT INTO toppers_cascaded VALUES (204, 'Wasim', 'CSE', 9.1);

SELECT * FROM students ORDER BY roll;
SELECT count(*) AS tara_in_toppers_local FROM toppers_local WHERE roll = 201;
""")

Output

CREATE TABLE
CREATE VIEW
CREATE VIEW
CREATE VIEW
INSERT 0 1
ERROR:  new row violates check option for view "cse"
DETAIL:  Failing row contains (202, Uday, ECE, 9.5).
ERROR:  new row violates check option for view "toppers_cascaded"
DETAIL:  Failing row contains (203, Vani, CSE, 8.0).
INSERT 0 1
 roll | name  | dept | cgpa 
------+-------+------+------
  201 | Tara  | ECE  |  9.5
  204 | Wasim | CSE  |  9.1
(2 rows)

 tara_in_toppers_local 
-----------------------
                     0
(1 row)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_options.py

toppers_local checked only cgpa >= 9, so Tara, an ECE student, was inserted through a view of CSE toppers. The last query shows the result: she is in the table, and invisible through the very view that inserted her. The cascaded view refused Uday because of the condition of cse, the view underneath, and its error names that view; it refused Vani because of its own condition, and accepted Wasim, who meets both. Use CASCADED unless you have a reason not to.

SQLite: read-only views and INSTEAD OF triggers

In SQLite every view is read-only. An INSTEAD OF trigger on a view tells SQLite what to do when a statement tries to change it: the trigger’s statements run instead, once for each row of the view the statement would have changed.

Making a SQLite view writable SQL · sqlite/instead_of.sql
CREATE TABLE students (
  roll INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  dept TEXT NOT NULL
);
INSERT INTO students VALUES (101, 'Asha', 'CSE'), (102, 'Ravi', 'ECE');
CREATE VIEW cse_students AS
  SELECT roll, name, dept FROM students WHERE dept = 'CSE';

-- Refused: in SQLite every view is read-only.
UPDATE cse_students SET name = 'Asha Rao' WHERE roll = 101;

-- An INSTEAD OF trigger says what an UPDATE of the view should do instead.
CREATE TRIGGER cse_students_update
INSTEAD OF UPDATE ON cse_students
BEGIN
  -- Refuse a change that would take the row out of the view.
  SELECT RAISE(ABORT, 'the row would leave cse_students')
  WHERE NEW.dept <> 'CSE';
  UPDATE students SET name = NEW.name, dept = NEW.dept
  WHERE roll = OLD.roll;
END;

UPDATE cse_students SET name = 'Asha Rao' WHERE roll = 101;
UPDATE cse_students SET dept = 'ME' WHERE roll = 101;
UPDATE cse_students SET name = 'nobody' WHERE roll = 102;
SELECT * FROM students ORDER BY roll;

Output (exit status 1)

┌──────┬──────────┬──────┐
│ roll │   name   │ dept │
├──────┼──────────┼──────┤
│ 101  │ Asha Rao │ CSE  │
│ 102  │ Ravi     │ ECE  │
└──────┴──────────┴──────┘

Printed as an error (standard error)

Parse error near line 11: cannot modify cse_students because it is a view
Runtime error near line 25: the row would leave cse_students

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < instead_of.sql

The first update was refused with “cannot modify cse_students because it is a view”. After the trigger existed, the name change went through. The move to ME was stopped by the trigger’s own check, which plays the part of a check option: RAISE(ABORT, …) ends the statement with an error and undoes what it had changed. The update of roll 102 did nothing, because Ravi is not in the view, so the trigger never fired for him.

PostgreSQL has INSTEAD OF triggers too, and they are how a view it cannot update by itself, such as dept_sizes, is made writable. A check option on a view that a trigger updates is ignored, so the trigger has to make its own checks, as this one does. The triggers lesson covers triggers in full.

Materialized views store the result

A materialized view runs its query when it is created and stores the rows. Reading it reads those rows, like a table. Here is what one read costs each way, for the same grouping of 100,000 enrolments:

Rows read to answer the same question Python · pg/matview_reads.py
# The work one read does: a view runs its query, a materialized view reads stored rows.
from pg_session import psql

psql("""
CREATE TABLE enrolments (roll integer, course text, PRIMARY KEY (roll, course));
INSERT INTO enrolments
  SELECT g, (ARRAY['DBMS', 'OS', 'CN', 'TOC'])[1 + g % 4] FROM generate_series(1, 100000) AS g;
ANALYZE enrolments;

CREATE VIEW course_sizes_v AS
  SELECT course, count(*) AS students FROM enrolments GROUP BY course;
CREATE MATERIALIZED VIEW course_sizes_m AS
  SELECT course, count(*) AS students FROM enrolments GROUP BY course;

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT * FROM course_sizes_v;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT * FROM course_sizes_m;
""")

Output

CREATE TABLE
INSERT 0 100000
ANALYZE
CREATE VIEW
SELECT 4
                          QUERY PLAN                          
--------------------------------------------------------------
 HashAggregate (actual rows=4.00 loops=1)
   Group Key: enrolments.course
   Batches: 1  Memory Usage: 32kB
   ->  Seq Scan on enrolments (actual rows=100000.00 loops=1)
(4 rows)

                      QUERY PLAN                       
-------------------------------------------------------
 Seq Scan on course_sizes_m (actual rows=4.00 loops=1)
(1 row)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 matview_reads.py

To return four rows, the view scanned all 100,000 enrolments and grouped them; the materialized view scanned its four stored rows. That saving is the reason to use one. The price is freshness:

Stale rows, a refresh and the locks it takes Python · pg/matview_refresh.py
# A materialized view keeps its rows until REFRESH computes them again.
from pg_session import psql

psql("""
CREATE TABLE enrolments (roll integer, course text, PRIMARY KEY (roll, course));
INSERT INTO enrolments VALUES (101, 'DBMS'), (102, 'DBMS'), (101, 'OS'), (103, 'CN');

CREATE MATERIALIZED VIEW course_sizes AS
  SELECT course, count(*) AS students FROM enrolments GROUP BY course;

INSERT INTO enrolments VALUES (103, 'DBMS'), (104, 'OS');
SELECT * FROM course_sizes ORDER BY course;      -- still the old counts

REFRESH MATERIALIZED VIEW CONCURRENTLY course_sizes;
CREATE UNIQUE INDEX ON course_sizes (course);
REFRESH MATERIALIZED VIEW CONCURRENTLY course_sizes;
SELECT * FROM course_sizes ORDER BY course;

UPDATE course_sizes SET students = 0;

-- The locks each kind of refresh holds on the materialized view until COMMIT.
BEGIN;
REFRESH MATERIALIZED VIEW course_sizes;
SELECT string_agg(mode, ', ' ORDER BY mode) AS plain_refresh_holds
FROM pg_locks WHERE relation = 'course_sizes'::regclass AND pid = pg_backend_pid();
COMMIT;
BEGIN;
REFRESH MATERIALIZED VIEW CONCURRENTLY course_sizes;
SELECT string_agg(mode, ', ' ORDER BY mode) AS concurrent_refresh_holds
FROM pg_locks WHERE relation = 'course_sizes'::regclass AND pid = pg_backend_pid();
COMMIT;

CREATE MATERIALIZED VIEW later AS SELECT * FROM enrolments WITH NO DATA;
SELECT count(*) FROM later;
""")

Output

CREATE TABLE
INSERT 0 4
SELECT 3
INSERT 0 2
 course | students 
--------+----------
 CN     |        1
 DBMS   |        2
 OS     |        1
(3 rows)

ERROR:  cannot refresh materialized view "public.course_sizes" concurrently
HINT:  Create a unique index with no WHERE clause on one or more columns of the materialized view.
CREATE INDEX
REFRESH MATERIALIZED VIEW
 course | students 
--------+----------
 CN     |        1
 DBMS   |        3
 OS     |        2
(3 rows)

ERROR:  cannot change materialized view "course_sizes"
BEGIN
REFRESH MATERIALIZED VIEW
              plain_refresh_holds              
-----------------------------------------------
 AccessExclusiveLock, ExclusiveLock, ShareLock
(1 row)

COMMIT
BEGIN
REFRESH MATERIALIZED VIEW
             concurrent_refresh_holds             
--------------------------------------------------
 AccessShareLock, ExclusiveLock, RowExclusiveLock
(1 row)

COMMIT
CREATE MATERIALIZED VIEW
ERROR:  materialized view "later" has not been populated
HINT:  Use the REFRESH MATERIALIZED VIEW command.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 matview_refresh.py

  • After two new enrolments the stored counts were still the old ones, until a refresh ran the query again.
  • REFRESH MATERIALIZED VIEW replaces the stored rows completely. The CONCURRENTLY form was refused at first: it needs a UNIQUE index on plain columns of the materialized view, with no WHERE clause and no expressions, so that it can match old rows with new ones. With the index it worked. It also needs the view to be populated already, and only one refresh of a materialized view can run at a time.
  • A materialized view cannot be changed directly; only a refresh changes it.
  • The locks explain when to choose which refresh. A plain refresh holds an ACCESS EXCLUSIVE lock until the transaction ends, and that lock blocks even a plain SELECT of the view. The concurrent refresh’s strongest lock is EXCLUSIVE, which still lets other sessions read the old rows while the new ones are computed. A plain refresh does less work when most rows change; a concurrent one keeps readers going and is often faster when few rows change.
  • Created WITH NO DATA, a materialized view cannot be read until its first refresh. Running REFRESH needs the MAINTAIN privilege on the materialized view, so a scheduled job can be given exactly that.
A view, a materialized view and a summary table kept by triggers
Criterion ViewMaterialized viewSummary table
Stores Its queryIts query and resultResult rows
Fresh AlwaysAs of the last refreshAfter every change
A read Runs the queryScans stored rowsScans stored rows
A write Costs nothing extraCosts nothing extraAlso runs the triggers
Indexes None of its ownIts ownIts own
Changed by The base tablesREFRESH onlyIts triggers only
SQLite Yes, read-onlyNoYes
When to choose Current rows, and a query cheap enough to run on every readMany reads, few changes, minutes-old data is fineCheap and current reads, and writes can pay a little more

Materialized results in SQLite

SQLite has no materialized views, so you build the effect yourself: a table for the result, and either a scheduled rebuild (delete its rows and insert the query’s result again, in one transaction) or triggers that keep it current as the data changes.

A summary table kept current by triggers SQL · sqlite/summary_table.sql
CREATE TABLE enrolments (
  roll   INTEGER NOT NULL,
  course TEXT NOT NULL,
  PRIMARY KEY (roll, course)
);
-- The stored result: one row per course, kept up to date by two triggers.
CREATE TABLE course_sizes (
  course   TEXT PRIMARY KEY,
  students INTEGER NOT NULL
);
CREATE TRIGGER enrolment_added AFTER INSERT ON enrolments
BEGIN
  INSERT INTO course_sizes VALUES (NEW.course, 1)
  ON CONFLICT (course) DO UPDATE SET students = students + 1;
END;
CREATE TRIGGER enrolment_removed AFTER DELETE ON enrolments
BEGIN
  UPDATE course_sizes SET students = students - 1 WHERE course = OLD.course;
  DELETE FROM course_sizes WHERE course = OLD.course AND students = 0;
END;

INSERT INTO enrolments VALUES (101, 'DBMS'), (102, 'DBMS'), (101, 'OS'), (103, 'CN');
INSERT INTO enrolments VALUES (103, 'DBMS'), (104, 'OS');
DELETE FROM enrolments WHERE course = 'CN';
SELECT * FROM course_sizes ORDER BY course;

-- The same numbers computed from scratch: what a full rebuild would store.
SELECT course, count(*) AS students FROM enrolments GROUP BY course ORDER BY course;

Output

┌────────┬──────────┐
│ course │ students │
├────────┼──────────┤
│ DBMS   │ 3        │
│ OS     │ 2        │
└────────┴──────────┘
┌────────┬──────────┐
│ course │ students │
├────────┼──────────┤
│ DBMS   │ 3        │
│ OS     │ 2        │
└────────┴──────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < summary_table.sql

Each insert adds one to its course, using ON CONFLICT … DO UPDATE to create the course’s row the first time, and each delete takes one away and removes a course that reaches zero. The last query computes the counts from scratch and agrees. Triggers like these have to cover every way the data can change: an UPDATE that moves an enrolment to another course would make the counts wrong here, because no trigger handles it.

SQLite Online (SQL Playground) Create views and triggers on your own tables: SQLite runs in your browser, like these examples.

Exercise: a view from a written specification

Exercise · Easy · SQL

A view for the scholarship committee

The table students has one row per student:

  • roll: the roll number;
  • name and dept;
  • cgpa: the grade point average, from 0 to 10;
  • attendance: the percentage of classes attended, or NULL when it has not been recorded yet;
  • phone: a contact number.

The scholarship committee may see only what it needs. Create a view named scholarship_shortlist with exactly three columns, roll, name and dept, and one row for every student whose cgpa is at least 8.5 and whose attendance is at least 75. Students whose attendance has not been recorded are not on the list. The phone numbers, the grades and the attendance stay out of the view.

The last line of the starter file reads the view, sorted by roll number. Leave it as it is: the checks compare what it returns.

Starter code · shortlist.sql

-- Create the view here: roll, name and dept of the students on the shortlist.
CREATE VIEW scholarship_shortlist AS
SELECT roll, name, dept, cgpa, phone
FROM students;

-- The check reads the view. Leave this line as it is.
SELECT * FROM scholarship_shortlist ORDER BY roll;
The sample tests · tests.yaml
# Sample tests: each one builds a small students table, runs your file (the view and the line that reads it) and
# compares the rows that line returns, in order.
tests:
  - name: the students above both limits, and only the three columns
    ordered: true
    setup: |
      CREATE TABLE students (roll INTEGER PRIMARY KEY, name TEXT, dept TEXT, cgpa REAL, attendance INTEGER, phone TEXT);
      INSERT INTO students VALUES
        (101, 'Asha',  'CSE', 9.1, 92, '90000 00101'),
        (102, 'Ravi',  'ECE', 8.7, 61, '90000 00102'),
        (103, 'Meera', 'CSE', 7.9, 98, '90000 00103'),
        (104, 'Kabir', 'ME',  8.8, 80, '90000 00104');
    expected:
      columns: [roll, name, dept]
      rows:
        - [101, 'Asha', 'CSE']
        - [104, 'Kabir', 'ME']
  - name: a cgpa of exactly 8.5 and an attendance of exactly 75 are enough
    ordered: true
    setup: |
      CREATE TABLE students (roll INTEGER PRIMARY KEY, name TEXT, dept TEXT, cgpa REAL, attendance INTEGER, phone TEXT);
      INSERT INTO students VALUES
        (201, 'Tara',  'CSE', 8.5, 75, '90000 00201'),
        (202, 'Uday',  'ECE', 8.4, 99, '90000 00202'),
        (203, 'Vani',  'IT',  9.6, 74, '90000 00203'),
        (204, 'Wasim', 'CSE', 8.5, 100, '90000 00204');
    expected:
      columns: [roll, name, dept]
      rows:
        - [201, 'Tara', 'CSE']
        - [204, 'Wasim', 'CSE']
  - name: attendance that was not recorded keeps a student off the list
    ordered: true
    setup: |
      CREATE TABLE students (roll INTEGER PRIMARY KEY, name TEXT, dept TEXT, cgpa REAL, attendance INTEGER, phone TEXT);
      INSERT INTO students VALUES
        (301, 'Zoya', 'CSE', 9.9, NULL, '90000 00301'),
        (302, 'Neel', 'ECE', 9.0, 88, '90000 00302'),
        (303, 'Isha', 'ME', NULL, 90, '90000 00303');
    expected:
      columns: [roll, name, dept]
      rows:
        - [302, 'Neel', 'ECE']
A hint

Start from the SELECT that answers the question, then put CREATE VIEW scholarship_shortlist AS in front of it. "At least" means >=, so a cgpa of exactly 8.5 is on the list. A NULL attendance makes attendance >= 75 unknown, and WHERE keeps only the rows for which the whole condition is true.

The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 A view is created over a table of 2 million orders. What does the database store for the view?

    Choose one answer.

    Show the answer to question 1

    Answer: Its query (as text in SQLite, as a parsed rule in PostgreSQL), and no rows

    A view keeps only its definition. Each query on the view runs that definition against the table, which is why a row inserted later shows up at once. Storing the rows is what a materialized view does.

  2. Question 2 of 5 In PostgreSQL 18, which of these views can take an UPDATE without any trigger or rule?

    Choose one answer.

    Show the answer to question 2

    Answer: SELECT roll, name FROM students WHERE dept = 'CSE'

    An automatically updatable view reads one table or updatable view and has no GROUP BY, DISTINCT, set operation, aggregate or window function at the top level, so each of its rows stands for exactly one row of the table. The grouped, DISTINCT and UNION views break that, so they are read-only unless an INSTEAD OF trigger handles changes.

  3. Question 3 of 5 cse is SELECT * FROM students WHERE dept = 'CSE' (no check option). toppers is SELECT * FROM cse WHERE cgpa >= 9 WITH LOCAL CHECK OPTION. What happens to INSERT INTO toppers VALUES (7, 'Tara', 'ECE', 9.5)?

    Choose one answer.

    Show the answer to question 3

    Answer: The row is inserted into students, and it is not visible through toppers

    LOCAL checks only the condition written in toppers itself, cgpa >= 9, which Tara meets. The dept condition belongs to cse, which has no check option, so nothing checks it. The row lands in students but fails cse's WHERE, so neither view shows it. With CASCADED the insert would have been refused.

  4. Question 4 of 5 Which of these are needed before REFRESH MATERIALIZED VIEW CONCURRENTLY can run on a materialized view? Choose every one.

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • A UNIQUE index on plain columns of the materialized view, without a WHERE clause
    • The materialized view is already populated (it was not left WITH NO DATA)

    A concurrent refresh compares the old rows with the new ones by a unique key, so it needs a unique index on columns (no expressions, no WHERE) and existing rows to compare with. Readers are the point: the concurrent form takes an EXCLUSIVE lock, which still lets plain SELECTs run, while a plain refresh blocks them.

  5. Question 5 of 5 A SQLite application needs per-course counts that are cheap to read and current after every enrolment. What does the lesson suggest?

    Choose one answer.

    Show the answer to question 5

    Answer: A table of counts kept current by AFTER INSERT and AFTER DELETE triggers on the enrolments table

    SQLite has no materialized views, and its views never store rows, with or without triggers. A summary table written by triggers holds the result, stays current with each write and costs a little extra on every change; every kind of change, updates included, needs a trigger for the counts to stay right.

Interview questions

What is the difference between a view and a materialized view? (warm-up)

A view stores only its query. Every read runs that query against the base tables, so the result is always current, it costs a full query each time, and nothing has to be refreshed. A materialized view stores the query and its result rows. Reads scan the stored rows, which is much cheaper for a heavy aggregate, but the rows are only as fresh as the last REFRESH MATERIALIZED VIEW, and the materialized view can have indexes of its own. In PostgreSQL a plain refresh locks out readers while it runs, and REFRESH … CONCURRENTLY keeps them reading but needs a unique index. SQLite has views but no materialized views; a table kept current by triggers, or rebuilt on a schedule, takes their place.

Key takeaways

  • A view stores a query, never rows. PostgreSQL rewrites a query on a view into a query on the tables, so a view costs nothing extra at run time.
  • Views restrict what users see, keep old queries working while tables change, and give long queries a name. List the view’s columns instead of writing *: PostgreSQL fixes * when the view is created, SQLite reads it again.
  • PostgreSQL updates through a view by itself when the view reads one table without grouping, DISTINCT, set operations or aggregates; only plain columns are updatable.
  • Without a check option, a change through a view may create rows the view cannot show. WITH CHECK OPTION refuses them; CASCADED, the default, also checks the views underneath, and LOCAL does not.
  • SQLite views are read-only; INSTEAD OF triggers make them writable, and their checks are your own.
  • A materialized view stores its result and is stale until REFRESH. A plain refresh blocks readers; CONCURRENTLY lets them read but needs a populated view and a unique index on plain columns.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.